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, }) }