- Admin-Tab "Abo-Angebote" und Button "Angebot" in der Kundenliste: Preis, Monate, Gueltigkeit, Hinweis fuer den Kunden, interne Notiz, Vorschau vor dem Anlegen - Je Angebot sofort ein Stripe-Coupon (amount_off, repeating ueber die Angebotsmonate, max. 1 Einloesung); danach automatisch wieder Normalpreis - Kunde ohne Abo: Angebot im Portal, Angebotspreis in der Bestelluebersicht, Coupon im Checkout; Angebot schaltet die Bestellung auch ausserhalb der Pilotliste frei; Bestellung prueft, dass das angezeigte Angebot noch gilt - Laufendes Abo: Coupon sofort am Abo fuer die naechsten Abbuchungen - Testkonten: Testrabatt im Checkout, Angebot per Webhook zusaetzlich am Abo - Angebot in Abo-Metadaten, Vertragsbestaetigung, Admin-Mail und Abo-Karte - Zurueckziehen loescht den Coupon; abgelaufene Angebote nicht mehr bestellbar - Stundenkontingent nach invoice.subtotal: auch voll rabattierte oder per Guthaben bezahlte Monate bringen die Stunde, die Probezeit nicht - setup-abo-collections.mjs: aboOffers, subscriptions.offerId/offerText, Attribut-Abfrage mit Limit (sonst Haenger ab 26 Attributen) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
386 lines
16 KiB
JavaScript
386 lines
16 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 { 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}`
|
||
}
|
||
|
||
export function adminOfferView(doc, customer) {
|
||
return {
|
||
...offerView(doc),
|
||
customerId: doc.customerId,
|
||
customerName: customer?.name || customer?.companyName || '(Kunde nicht gefunden)',
|
||
customerCode: customer?.code || '',
|
||
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 }
|
||
: 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,
|
||
}
|
||
}
|
||
|
||
async function mailOffer(doc, customer, sub) {
|
||
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${sub?.currentPeriodEnd ? ` am ${fmtDate(sub.currentPeriodEnd)}` : ''}:`
|
||
: '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`,
|
||
'',
|
||
'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,
|
||
]
|
||
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
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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, sub).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 }
|
||
}
|
||
|
||
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),
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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,
|
||
})
|
||
}
|