Files
Webklar-Kundenbereich/server/services/offers.js
T

481 lines
21 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 { getProduct, PRODUCT_KEYS } from './products.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 Ticketsystem 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 Ticketsystem)
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())
// Altbestand ohne productKey (vor Aufgabe 11) gilt als Wartungs-Abo/-Angebot (F32)
const productKeyOf = (doc) => doc?.productKey || 'wartung'
/** Katalogname ohne "WEBklar " (z. B. "Hosting-Abo"), fuer {{produkt}} in Mails (F33). */
const productLabel = (key) => getProduct(key).name.replace(/^WEBklar\s+/, '')
// ---------------------------------------------------------------------------
// 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,
productKey: productKeyOf(doc),
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 fuer ein Produkt (hoechstens eines je Produkt, siehe createOffer). */
export async function getOpenOffer(customerId, productKey) {
if (!customerId || !productKey) 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) && productKeyOf(d) === productKey) || null
}
/** Fuer das Angebotsformular: Abo-Stand, offene Angebote je Produkt, 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, hostingOpen, wartungOpen] = await Promise.all([
getCurrentSubscription(customerId),
getOpenOffer(customerId, 'hosting'),
getOpenOffer(customerId, 'wartung'),
])
const running = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
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: running,
// Nur fuer dieses Produkt kann ein Angebot sofort aufs Abo angewendet werden (F32)
productKey: productKeyOf(sub),
currentPeriodEnd: sub.currentPeriodEnd || null,
// Laufendes Abo: [0] = erste rabattierte Abbuchung, [N] = Rabattende bei N Angebotsmonaten
upcomingCharges: running ? chargesFrom(sub.trialEnd || sub.startedAt, new Date(), config.offers.maxMonths + 1) : [],
}
: null,
openOffers: {
hosting: hostingOpen ? offerView(hostingOpen) : null,
wartung: wartungOpen ? offerView(wartungOpen) : null,
},
products: {
hosting: { regularPriceCents: getProduct('hosting').amountCents },
wartung: { regularPriceCents: getProduct('wartung').amountCents },
},
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 productKey = body?.productKey
if (!PRODUCT_KEYS.includes(productKey)) {
throw httpError(400, 'Bitte ein Produkt wählen (Hosting-Abo oder Wartungs-Abo).')
}
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 = getProduct(productKey).amountCents
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 {
productKey,
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 || '',
produkt: productLabel(productKeyOf(doc)),
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, input.productKey)) {
throw httpError(409, 'Für dieses Produkt gibt es für diesen Kunden schon ein offenes Angebot. Bitte das alte zuerst zurückziehen.')
}
const sub = await getCurrentSubscription(customer.$id)
// Sofort anwendbar ist ein Angebot nur, wenn das laufende Abo dasselbe Produkt betrifft (F32)
const running = Boolean(sub && ACTIVE_STATUSES.has(sub.status) && productKeyOf(sub) === input.productKey)
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
}
// Angebot und Bestellung fuer unterschiedliche Produkte (F32/F33): nichts anwenden, T03 mit Grund
const metaProductKey = session.metadata?.productKey
if (metaProductKey && productKeyOf(doc) !== metaProductKey) {
await sendAdminNotice({
template: 'T03',
values: {
angebot_nr: shortRef(offerId),
stripe_abo_id: subscriptionId,
angebot_text: offerText(doc),
angebot_status: `Produkt passt nicht: Angebot für ${productLabel(productKeyOf(doc))}, bestellt wurde ${productLabel(metaProductKey)}`,
},
})
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,
})
}