481 lines
21 KiB
JavaScript
481 lines
21 KiB
JavaScript
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,
|
||
})
|
||
}
|