Files
Webklar-Kundenbereich/server/services/offers.js
T
ClaudeandClaude Opus 5.5 18c3cdf3e5 Automatische Mails im neuen Design (HTML mit Textfassung)
Alle 22 automatischen Mails gehen jetzt als HTML nach den Vorlagen aus
server/mail-templates raus, dazu eine aus dem HTML erzeugte Textfassung
(multipart/alternative). Werte werden HTML-sicher eingesetzt, optionale
Blöcke (WENN/ENDE) je nach Fall ein- oder ausgeblendet.

- Neu: services/mailTemplates.js (Vorlagen lesen, Platzhalter, Blöcke,
  Textfassung); mailer.js nimmt { template, values, blocks }.
- K11 "Passwort geändert" war nicht im Design-Export: aus den Bausteinen
  von K02 zusammengesetzt.
- T09 (Widerruf, intern): Fälle "kein Abo gefunden" und "Stripe-Kündigung
  fehlgeschlagen" ergänzt; bisher stand dort immer "auf Kündigung gesetzt".
- Einheitlich: Datum TT.MM.JJJJ, Beträge deutsch (T04 "50,00 €"),
  "WEBklar" statt "Webklar", Anrede mit Kundennamen, Team-Absender
  "WEBklar Kundenportal", Betreff T05 "Kündigung (Stripe)".
- Logo für die Mails: public/webklar-logo.png (192 px, aus dem Design-SVG).

Tests: 52 neue (jede Vorlage: Kopf, Platzhalter, Blöcke, Rendern,
Escaping, Textfassung), npm test 97/97 auf Node 20.19.5; alle 40 Auslöser
einmal durch den Server geschickt (Appwrite-Mock, Stripe-Double,
SMTP-Falle mit STARTTLS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:37:50 +00:00

441 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { randomUUID } from 'node:crypto'
import { config } from '../config.js'
import { createDocument, deleteDocument, getDocument, listDocuments, Query, updateDocument } from './appwriteAdmin.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { chargesFrom, redeemedDiscount } from './offerDiscount.js'
import { ACTIVE_STATUSES, getCurrentSubscription, getStripe, isStripeConfigured } from './stripeBilling.js'
import { shortRef } from './takeoverCredits.js'
/**
* Individuelle Abo-Angebote.
*
* Der Admin vereinbart mit einem Kunden einen Sonderpreis fuer die ersten Monate (z. B. 2 Monate
* je 25 € statt 50 €) und legt ihn vorab im Admin-Panel an. Jedes Angebot bekommt sofort einen
* eigenen, einmal einloesbaren Stripe-Coupon (amount_off, duration "repeating" ueber die
* Angebotsmonate); danach gilt automatisch wieder der regulaere Preis.
* - Kunde ohne Abo: Portal zeigt das Angebot, die Bestelluebersicht nennt den Angebotspreis und
* der Checkout wendet den Coupon an. Testkonten bekommen im Checkout weiter ihren Testrabatt,
* das Angebot wird nach dem Abschluss zusaetzlich ans Abo gehaengt (Webhook).
* - Kunde mit laufendem Abo: Der Coupon gilt sofort fuer die naechsten Abbuchungen.
*/
const COLLECTION = () => config.collections.aboOffers
export const OFFER_STATUS = { open: 'offen', active: 'aktiv', withdrawn: 'zurueckgezogen' }
// Anzeige im Team-Hinweis (wie im Admin-Bereich)
const OFFER_STATUS_LABEL = { offen: 'offen', aktiv: 'aktiv (schon eingelöst)', zurueckgezogen: 'zurückgezogen' }
function httpError(status, message) {
const err = new Error(message)
err.status = status
return err
}
const euro = (cents) =>
(Number(cents || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }).replace(/ /g, ' ')
const fmtDate = (iso) =>
iso
? new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
: '–'
const clean = (value, max) => String(value ?? '').trim().slice(0, max)
const todayBerlin = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
function addDaysYmd(ymd, days) {
const d = new Date(`${ymd}T00:00:00Z`)
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
const adminLabel = (admin) =>
admin?.name ? `${admin.name}${admin.email ? ` <${admin.email}>` : ''}` : admin?.email || 'Admin'
const isTestEmail = (email) => config.abo.testEmails.includes(String(email || '').toLowerCase())
// ---------------------------------------------------------------------------
// Texte (eine Quelle fuer Portal, Bestelluebersicht, Checkout, Mails und Stripe)
// ---------------------------------------------------------------------------
/** "Erste 2 Monate je 25,00 € statt 50,00 €, danach 50,00 € pro Monat" */
export function offerText({ monthlyPriceCents: price, regularPriceCents: regular, months, scope }) {
const running = scope === 'laufend'
const lead = months === 1
? (running ? 'Nächster Monat' : 'Erster Monat')
: `${running ? 'Nächste' : 'Erste'} ${months} Monate`
const amount = price === 0 ? 'kostenlos' : `${months === 1 ? '' : 'je '}${euro(price)} statt ${euro(regular)}`
return `${lead} ${amount}, danach ${euro(regular)} pro Monat`
}
/** Bezeichnung des Rabatts auf Stripe-Checkout und -Rechnungen (max. 40 Zeichen) */
function couponName({ monthlyPriceCents: price, months }) {
const amount = price === 0 ? 'kostenlos' : `${months === 1 ? '' : 'je '}${euro(price)}`
return `Angebot: ${months === 1 ? '1 Monat' : `${months} Monate`} ${amount}`.slice(0, 40)
}
const savingsOf = (doc) => (doc.regularPriceCents - doc.monthlyPriceCents) * doc.months
// validUntil = Kalendertag (UTC-Mitternacht gespeichert), gueltig bis einschliesslich dieses Tages in Berlin
const isExpired = (doc) => Boolean(doc.validUntil) && todayBerlin() > String(doc.validUntil).slice(0, 10)
export function offerView(doc) {
return {
id: doc.$id,
ref: shortRef(doc.$id),
createdAt: doc.$createdAt,
status: doc.status === OFFER_STATUS.open && isExpired(doc) ? 'abgelaufen' : doc.status,
scope: doc.scope,
months: doc.months,
monthlyPriceCents: doc.monthlyPriceCents,
regularPriceCents: doc.regularPriceCents,
savingsCents: savingsOf(doc),
text: offerText(doc),
validUntil: doc.validUntil || null,
note: doc.note || '',
}
}
function stripeDashboardUrl(kind, id) {
if (!id) return ''
const test = /^(sk|rk)_test_/.test(config.stripe.secretKey) ? 'test/' : ''
return `https://dashboard.stripe.com/${test}${kind}/${id}`
}
/**
* sub = Abo-Dokument des eingeloesten Angebots (doc.stripeSubscriptionId), fuer das Rabattende.
* Testkonten bekommen den Coupon erst nach dem Abschluss (activateOfferFromCheckout), wie ein laufendes Abo.
*/
export function adminOfferView(doc, customer, sub = null) {
const view = offerView(doc)
const testAccount = isTestEmail(customer?.email)
const attachedLater = doc.scope === 'laufend' || testAccount
const discount = doc.status === OFFER_STATUS.active
? redeemedDiscount({ months: doc.months, appliedAt: doc.appliedAt, attachedLater }, sub)
: null
// Angebots-Mail (erneut) senden: solange das Angebot offen und gueltig ist, bei laufendem Abo solange
// die erste rabattierte Abbuchung noch bevorsteht (die Mail nennt sie als "naechste Abbuchung")
const mailSendable = Boolean(customer?.email) && (
view.status === OFFER_STATUS.open ||
(doc.status === OFFER_STATUS.active && doc.scope === 'laufend' && Boolean(discount?.known) && new Date(discount.firstChargeAt) > new Date())
)
return {
...view,
testAccount,
discount,
mailSendable,
customerId: doc.customerId,
customerName: customer?.name || customer?.companyName || '(Kunde nicht gefunden)',
customerCode: customer?.code || '',
customerEmail: customer?.email || '',
internalNote: doc.internalNote || '',
createdBy: doc.createdBy || '',
notifiedAt: doc.notifiedAt || null,
appliedAt: doc.appliedAt || null,
withdrawnAt: doc.withdrawnAt || null,
withdrawnBy: doc.withdrawnBy || '',
stripeCouponId: doc.stripeCouponId || '',
stripeCouponUrl: stripeDashboardUrl('coupons', doc.stripeCouponId),
stripeSubscriptionId: doc.stripeSubscriptionId || '',
stripeSubscriptionUrl: stripeDashboardUrl('subscriptions', doc.stripeSubscriptionId),
}
}
// ---------------------------------------------------------------------------
// Lesen
// ---------------------------------------------------------------------------
export async function listOffers() {
return listDocuments(COLLECTION(), [Query.orderDesc('$createdAt'), Query.limit(1000)])
}
/** Offenes, noch gueltiges Angebot eines Kunden (hoechstens eines, siehe createOffer). */
export async function getOpenOffer(customerId) {
if (!customerId) return null
const docs = await listDocuments(COLLECTION(), [
Query.equal('customerId', customerId),
Query.equal('status', OFFER_STATUS.open),
Query.orderDesc('$createdAt'),
Query.limit(20),
])
return docs.find((d) => !isExpired(d)) || null
}
/** Fuer das Angebotsformular: Abo-Stand, offenes Angebot, Portalzugang, Testkonto. */
export async function offerContext(customerId) {
const customer = await getDocument(config.collections.customers, customerId).catch(() => null)
if (!customer) throw httpError(404, 'Kunde nicht gefunden')
const [sub, open] = await Promise.all([getCurrentSubscription(customerId), getOpenOffer(customerId)])
return {
customer: {
id: customer.$id,
name: customer.name || customer.companyName || '',
code: customer.code || '',
email: customer.email || '',
portalAccessEnabled: Boolean(customer.portalAccessEnabled),
testAccount: isTestEmail(customer.email),
},
subscription: sub
? {
status: sub.status,
active: ACTIVE_STATUSES.has(sub.status),
currentPeriodEnd: sub.currentPeriodEnd || null,
// Laufendes Abo: [0] = erste rabattierte Abbuchung, [N] = Rabattende bei N Angebotsmonaten
upcomingCharges: ACTIVE_STATUSES.has(sub.status)
? chargesFrom(sub.trialEnd || sub.startedAt, new Date(), config.offers.maxMonths + 1)
: [],
}
: null,
openOffer: open ? offerView(open) : null,
regularPriceCents: config.stripe.priceAmountCents,
maxMonths: config.offers.maxMonths,
defaultValidUntil: addDaysYmd(todayBerlin(), config.offers.defaultValidityDays),
}
}
// ---------------------------------------------------------------------------
// Stripe
// ---------------------------------------------------------------------------
function couponIdOf(discount) {
if (!discount || typeof discount === 'string') return null
// Seit API 2025-03 steckt der Coupon unter discount.source.coupon
const c = discount.source?.coupon ?? discount.coupon
return typeof c === 'string' ? c : c?.id || null
}
/** Coupon zusaetzlich zu vorhandenen Rabatten ans Abo haengen (idempotent). */
async function attachCouponToSubscription(subscriptionId, couponId, metadata) {
const stripe = getStripe()
const sub = await stripe.subscriptions.retrieve(subscriptionId, { expand: ['discounts'] })
const existing = (sub.discounts || []).filter(Boolean)
if (existing.some((d) => couponIdOf(d) === couponId)) return sub
return stripe.subscriptions.update(subscriptionId, {
discounts: [...existing.map((d) => ({ discount: typeof d === 'string' ? d : d.id })), { coupon: couponId }],
// Leeres metadata-Objekt wuerde bei Stripe ALLE Schluessel loeschen (auch webklarCustomerId)
...(metadata ? { metadata } : {}),
})
}
// ---------------------------------------------------------------------------
// Admin: anlegen und zurueckziehen
// ---------------------------------------------------------------------------
export function validateOfferInput(body) {
const months = Number(body?.months)
if (!Number.isInteger(months) || months < 1 || months > config.offers.maxMonths) {
throw httpError(400, `Bitte 1 bis ${config.offers.maxMonths} Angebotsmonate wählen.`)
}
const regularPriceCents = config.stripe.priceAmountCents
const monthlyPriceCents = Number(body?.monthlyPriceCents)
if (!Number.isInteger(monthlyPriceCents) || monthlyPriceCents < 0 || monthlyPriceCents >= regularPriceCents) {
throw httpError(400, `Der Angebotspreis muss zwischen 0,00 € und ${euro(regularPriceCents - 1)} liegen.`)
}
const today = todayBerlin()
const validUntil = /^\d{4}-\d{2}-\d{2}$/.test(String(body?.validUntil || ''))
? body.validUntil
: addDaysYmd(today, config.offers.defaultValidityDays)
if (validUntil < today || validUntil > addDaysYmd(today, config.offers.maxValidityDays)) {
throw httpError(400, 'Bitte ein Gültigkeitsdatum ab heute und höchstens ein Jahr im Voraus wählen.')
}
return {
months,
monthlyPriceCents,
regularPriceCents,
validUntil: `${validUntil}T00:00:00.000Z`,
note: clean(body?.note, 500),
internalNote: clean(body?.internalNote, 2000),
notifyCustomer: body?.notifyCustomer !== false,
}
}
/**
* Angebots-Mail an den Kunden (Kopie per BCC ans Team). nextChargeAt: bei laufendem Abo die erste
* rabattierte Abbuchung. Wirft mit Grund, wenn der Mailserver die Mail oder den Kunden als Empfaenger
* nicht annimmt; Ergebnis und Fehler stehen im Server-Log.
*/
async function mailOffer(doc, customer, nextChargeAt = null) {
const running = doc.scope === 'laufend'
const values = {
name: customer.name || '',
angebot_text: offerText(doc),
ersparnis: euro(savingsOf(doc)),
hinweis: doc.note || '',
// K06 (laufendes Abo): ab wann der Rabatt gilt; K05: bis wann das Angebot angenommen werden kann
...(running ? { naechste_abbuchung: fmtDate(nextChargeAt) } : { gueltig_bis: fmtDate(doc.validUntil) }),
}
const ref = shortRef(doc.$id)
try {
const info = await sendCustomerMail(customer.email, {
template: running ? 'K06' : 'K05',
values,
blocks: { hinweis: Boolean(doc.note) },
})
// Lehnt der Mailserver nur den Kunden ab, geht die BCC-Kopie trotzdem raus und sendMail meldet Erfolg
const to = String(customer.email).toLowerCase()
if ((info?.rejected || []).some((r) => String(r?.address || r).toLowerCase() === to)) {
throw new Error(`Mailserver hat die Adresse abgelehnt (${info.rejectedErrors?.[0]?.response || info.response || 'ohne Antwort'})`)
}
console.log(`[offers] Angebots-Mail ${ref} vom Mailserver angenommen: ${info?.messageId || ''} ${info?.response || ''}`)
} catch (err) {
console.error(`[offers] Angebots-Mail ${ref} fehlgeschlagen:`, err.message)
throw err
}
}
/**
* Angebot anlegen: Dokument -> Stripe-Coupon -> (bei laufendem Abo) sofort anwenden -> Mail.
* Schlaegt ein Stripe-Schritt fehl, wird alles Angelegte wieder entfernt.
*/
export async function createOffer(body, admin) {
if (!isStripeConfigured()) throw httpError(503, 'Stripe ist nicht eingerichtet.')
const input = validateOfferInput(body)
const customer = await getDocument(config.collections.customers, String(body?.customerId || '')).catch(() => null)
if (!customer) throw httpError(404, 'Kunde nicht gefunden')
if (await getOpenOffer(customer.$id)) {
throw httpError(409, 'Für diesen Kunden gibt es schon ein offenes Angebot. Bitte das alte zuerst zurückziehen.')
}
const sub = await getCurrentSubscription(customer.$id)
const running = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
const id = randomUUID()
const { notifyCustomer, ...fields } = input
let doc = await createDocument(
COLLECTION(),
{
...fields,
customerId: customer.$id,
status: OFFER_STATUS.open,
scope: running ? 'laufend' : 'neu',
stripeCouponId: `WEBKLAR-ANGEBOT-${shortRef(id)}`,
createdBy: adminLabel(admin),
},
id
)
const stripe = getStripe()
try {
await stripe.coupons.create({
id: doc.stripeCouponId,
name: couponName(doc),
amount_off: doc.regularPriceCents - doc.monthlyPriceCents,
currency: 'eur',
duration: 'repeating',
duration_in_months: doc.months,
max_redemptions: 1,
metadata: { webklar: 'abo-angebot', webklarOfferId: id, webklarCustomerId: customer.$id },
})
} catch (err) {
await deleteDocument(COLLECTION(), id).catch(() => {})
throw httpError(502, `Stripe hat den Coupon abgelehnt: ${err.message}`)
}
if (running) {
try {
await attachCouponToSubscription(sub.stripeSubscriptionId, doc.stripeCouponId, {
offerId: id,
offerText: offerText(doc).slice(0, 300),
})
} catch (err) {
await stripe.coupons.del(doc.stripeCouponId).catch(() => {})
await deleteDocument(COLLECTION(), id).catch(() => {})
throw httpError(502, `Stripe: Angebot konnte nicht auf das laufende Abo angewendet werden (${err.message})`)
}
doc = await updateDocument(COLLECTION(), id, {
status: OFFER_STATUS.active,
appliedAt: new Date().toISOString(),
stripeSubscriptionId: sub.stripeSubscriptionId,
})
}
let mail = { sent: false, reason: 'nicht gewünscht' }
if (notifyCustomer) {
if (!customer.email) {
mail = { sent: false, reason: 'Kunde hat keine E-Mail-Adresse' }
} else {
mail = await mailOffer(doc, customer, running ? sub.currentPeriodEnd : null).then(
() => ({ sent: true }),
(e) => ({ sent: false, reason: e.message })
)
if (mail.sent) doc = await updateDocument(COLLECTION(), id, { notifiedAt: new Date().toISOString() })
}
}
return { doc, customer, mail, sub: running ? sub : null }
}
export async function withdrawOffer(id, admin) {
const doc = await getDocument(COLLECTION(), id)
if (doc.status !== OFFER_STATUS.open) {
throw httpError(409, 'Nur offene Angebote können zurückgezogen werden. Ein aktives Angebot bitte in Stripe am Abo entfernen.')
}
if (doc.stripeCouponId && isStripeConfigured()) {
await getStripe()
.coupons.del(doc.stripeCouponId)
.catch((err) => {
if (err.code !== 'resource_missing') throw httpError(502, `Stripe: Coupon konnte nicht gelöscht werden (${err.message})`)
})
}
return updateDocument(COLLECTION(), id, {
status: OFFER_STATUS.withdrawn,
withdrawnAt: new Date().toISOString(),
withdrawnBy: adminLabel(admin),
})
}
/** Angebots-Mail (erneut) senden, z. B. wenn sie beim Kunden nicht angekommen ist. */
export async function resendOfferMail(id) {
const doc = await getDocument(COLLECTION(), id).catch(() => null)
if (!doc) throw httpError(404, 'Angebot nicht gefunden')
const customer = await getDocument(config.collections.customers, doc.customerId).catch(() => null)
if (!customer) throw httpError(404, 'Kunde nicht gefunden')
if (!customer.email) throw httpError(400, 'Beim Kunden ist keine E-Mail-Adresse hinterlegt.')
const sub = doc.stripeSubscriptionId
? await getDocument(config.collections.subscriptions, doc.stripeSubscriptionId).catch(() => null)
: null
const { mailSendable, discount } = adminOfferView(doc, customer, sub)
if (!mailSendable) {
throw httpError(409, 'Für dieses Angebot geht keine E-Mail mehr raus: Es ist abgelaufen, zurückgezogen oder schon eingelöst.')
}
try {
await mailOffer(doc, customer, doc.scope === 'laufend' ? discount.firstChargeAt : null)
} catch (err) {
throw httpError(502, `E-Mail nicht verschickt: ${err.message}`)
}
return updateDocument(COLLECTION(), id, { notifiedAt: new Date().toISOString() })
}
// ---------------------------------------------------------------------------
// Checkout abgeschlossen (Stripe-Webhook)
// ---------------------------------------------------------------------------
/** Angebot aus den Checkout-Metadaten als eingeloest markieren (Testkonto: Coupon jetzt anhaengen). */
export async function activateOfferFromCheckout(session) {
const offerId = session.metadata?.offerId
const subscriptionId = typeof session.subscription === 'string' ? session.subscription : session.subscription?.id
if (!offerId || !subscriptionId) return null
const doc = await getDocument(COLLECTION(), offerId).catch(() => null)
if (!doc || doc.status === OFFER_STATUS.active) return doc
if (doc.status !== OFFER_STATUS.open) {
await sendAdminNotice({
template: 'T03',
values: {
angebot_nr: shortRef(offerId),
stripe_abo_id: subscriptionId,
angebot_text: offerText(doc),
angebot_status: OFFER_STATUS_LABEL[doc.status] || doc.status,
},
})
return null
}
if (session.metadata?.offerDeferred === '1') {
// Testkonto: Im Checkout lief der Testrabatt, das Angebot kommt jetzt zusaetzlich ans Abo
await attachCouponToSubscription(subscriptionId, doc.stripeCouponId)
}
return updateDocument(COLLECTION(), offerId, {
status: OFFER_STATUS.active,
appliedAt: new Date().toISOString(),
stripeSubscriptionId: subscriptionId,
})
}