Abo: individuelle Angebote (z. B. erste 2 Monate je 25 EUR) mit Stripe-Coupon

- 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>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-25 20:32:08 +00:00
1 parent 48b13f4488
commit bb2f46e191
11 files changed
+856 -30

No files matched your search

+385
View File
@@ -0,0 +1,385 @@
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,
})
}