Files
Webklar-Kundenbereich/server/services/offers.js
T
ClaudeandClaude Opus 5.5 32e441c8bc Abo-Angebote: Mail-Status in der Liste und E-Mail erneut senden
Bisher sah man nirgends, ob die Angebots-Mail rausging, Fehler landeten
nicht im Log, und senden ließ sie sich nur beim Anlegen.
- Angebotsliste: Kunden-Adresse unter dem Namen, im Status "E-Mail
  gesendet <Zeitpunkt>" bzw. "E-Mail nicht verschickt" / "Keine
  E-Mail-Adresse hinterlegt"
- Knopf "E-Mail senden" / "Erneut senden" mit Rückfrage (Adresse, letzter
  Versand), solange das Angebot offen und gültig ist; bei laufendem Abo,
  solange die erste rabattierte Abbuchung noch bevorsteht (die Mail
  nennt sie). POST /api/portal-admin/offers/:id/notify setzt notifiedAt.
- Server-Log: "[offers] Angebots-Mail <Ref> vom Mailserver angenommen:
  <Message-ID> 250 … queued as …" bzw. "… fehlgeschlagen: <Grund>"
- Lehnt der Mailserver nur den Kunden ab (die BCC-Kopie ans Team geht
  trotzdem raus), gilt die Mail als nicht verschickt
- sendCustomerMail gibt die nodemailer-Info zurück

Tests: npm test (40), Server-Durchlauf gegen Appwrite-Mock, SMTP-Sink
und Stripe-Double (28 Prüfungen), Oberfläche (18 Prüfungen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:12:39 +00:00

454 lines
20 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' }
const FOOTER = 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com'
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 lines = [
`Guten Tag ${customer.name || ''}`.trim() + ',',
'',
running
? `wie besprochen gilt für Ihr WEBklar Wartungs-Abo ab der nächsten Abbuchung${nextChargeAt ? ` am ${fmtDate(nextChargeAt)}` : ''}:`
: 'wie besprochen haben wir Ihnen im Kundenportal ein persönliches Angebot für das WEBklar Wartungs-Abo hinterlegt:',
'',
` Angebot: ${offerText(doc)}`,
` Ihre Ersparnis: ${euro(savingsOf(doc))}`,
...(running ? [] : [` Gültig bis: ${fmtDate(doc.validUntil)}`]),
...(doc.note ? [` Hinweis: ${doc.note}`] : []),
'',
...(running
? ['Der Rabatt wird bei den Abbuchungen automatisch berücksichtigt und erscheint auf Ihren Rechnungen.']
: [
`Sie können das Abo zu diesem Preis bis zum ${fmtDate(doc.validUntil)} im Kundenportal abschließen:`,
`${config.publicBaseUrl}/dashboard.html#abo`,
'',
'Es gelten unsere Vertragsbedingungen (Mindestlaufzeit 12 Monate, danach monatlich kündbar; gemäß § 19 UStG',
`ohne Umsatzsteuer): ${config.publicBaseUrl}/abo-bedingungen.html`,
]),
'',
'Viele Grüße',
'Ihr WEBklar-Team',
'',
FOOTER,
]
const ref = shortRef(doc.$id)
try {
const info = await sendCustomerMail(
customer.email,
running ? 'Ihr Angebot für das WEBklar Wartungs-Abo ist eingerichtet' : 'Ihr persönliches Angebot für das WEBklar Wartungs-Abo',
lines
)
// 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(`Angebot ${shortRef(offerId)} war beim Abschluss nicht mehr offen`, [
`Abo ${subscriptionId} wurde mit dem Angebot „${offerText(doc)}“ abgeschlossen, das Angebot ist aber „${doc.status}“.`,
'Bitte in Stripe prüfen, ob der Rabatt am Abo hängt, und ggf. korrigieren.',
])
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,
})
}