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:
1 parent
48b13f4488
commit
bb2f46e191
11 files changed
+856
-30
No files matched your search
@@ -51,6 +51,7 @@ export const config = {
|
||||
subscriptions: process.env.APPWRITE_COLLECTION_SUBSCRIPTIONS || 'subscriptions',
|
||||
hourCredits: process.env.APPWRITE_COLLECTION_HOUR_CREDITS || 'hourCredits',
|
||||
takeoverCredits: process.env.APPWRITE_COLLECTION_TAKEOVER_CREDITS || 'takeoverCredits',
|
||||
aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers',
|
||||
},
|
||||
|
||||
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
|
||||
@@ -96,6 +97,13 @@ export const config = {
|
||||
testCouponId: 'WEBKLAR-TEST-050',
|
||||
},
|
||||
|
||||
// Individuelle Abo-Angebote (z. B. erste 2 Monate je 25 €): je Angebot ein einmal einloesbarer Stripe-Coupon
|
||||
offers: {
|
||||
maxMonths: 12,
|
||||
defaultValidityDays: 30,
|
||||
maxValidityDays: 366,
|
||||
},
|
||||
|
||||
// Uebernahmegutschrift: beim bisherigen Anbieter bereits bezahlte Zeit wird mit den Abo-Gebuehren verrechnet
|
||||
takeover: {
|
||||
// Bei jeder Textaenderung an den Bedingungen der Gutschrift hochziehen (wird mit dem Antrag gespeichert)
|
||||
|
||||
+41
-14
@@ -7,6 +7,7 @@ import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js'
|
||||
import { getLedger } from '../services/hourLedger.js'
|
||||
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
|
||||
import { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js'
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
cancelAtPeriodEnd,
|
||||
@@ -72,13 +73,16 @@ function publicSubscription(sub) {
|
||||
// Widerrufsbutton im Portal nur waehrend der Frist hervorheben
|
||||
withdrawalOpen: Boolean(active && withdrawalUntil && withdrawalUntil > new Date()),
|
||||
withdrawalUntil: withdrawalUntil ? withdrawalUntil.toISOString() : null,
|
||||
// Individuelles Angebot, mit dem das Abo laeuft (z. B. "Erste 2 Monate je 25,00 € ...")
|
||||
offerText: sub.offerText || null,
|
||||
}
|
||||
}
|
||||
|
||||
// ABO_PILOT_EMAILS: Liste erlaubter Kunden-E-Mails; '*' = alle Kunden (Regelbetrieb)
|
||||
function isPilotAllowed(customer) {
|
||||
// ABO_PILOT_EMAILS: Liste erlaubter Kunden-E-Mails; '*' = alle Kunden (Regelbetrieb).
|
||||
// Ein individuelles Angebot schaltet die Bestellung fuer diesen Kunden ebenfalls frei.
|
||||
function isPilotAllowed(customer, offer = null) {
|
||||
const list = config.abo.pilotEmails
|
||||
if (!list.length || list.includes('*')) return true
|
||||
if (offer || !list.length || list.includes('*')) return true
|
||||
return list.includes(String(customer?.email || '').toLowerCase())
|
||||
}
|
||||
|
||||
@@ -95,12 +99,16 @@ router.get('/', requireSession, async (req, res) => {
|
||||
getDocument(config.collections.customers, customerId).catch(() => null),
|
||||
])
|
||||
const active = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
|
||||
const ledger = sub ? await getLedger(customerId, { active }) : null
|
||||
const [ledger, offer] = await Promise.all([
|
||||
sub ? getLedger(customerId, { active }) : null,
|
||||
active ? null : getOpenOffer(customerId).catch(() => null),
|
||||
])
|
||||
return res.json({
|
||||
available: isStripeConfigured() && isPilotAllowed(customer),
|
||||
available: isStripeConfigured() && isPilotAllowed(customer, offer),
|
||||
testAccount: config.abo.testEmails.includes(String(customer?.email || '').toLowerCase()),
|
||||
termsVersion: config.abo.termsVersion,
|
||||
subscription: publicSubscription(sub),
|
||||
offer: offer ? offerView(offer) : null,
|
||||
ledger,
|
||||
})
|
||||
} catch (err) {
|
||||
@@ -114,7 +122,7 @@ router.post('/checkout', requireSession, async (req, res) => {
|
||||
if (req.session.viewAs) {
|
||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' })
|
||||
}
|
||||
const { acceptTerms, startChoice, termsVersion } = req.body || {}
|
||||
const { acceptTerms, startChoice, termsVersion, offerId } = req.body || {}
|
||||
if (acceptTerms !== true) {
|
||||
return res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.' })
|
||||
}
|
||||
@@ -125,18 +133,27 @@ router.post('/checkout', requireSession, async (req, res) => {
|
||||
return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
|
||||
}
|
||||
const customer = await getDocument(config.collections.customers, getSessionCustomerId(req))
|
||||
if (!isStripeConfigured() || !isPilotAllowed(customer)) {
|
||||
// Bestellt wird genau der Preis, den die Bestelluebersicht gezeigt hat
|
||||
const offer = await getOpenOffer(customer.$id)
|
||||
if ((offer?.$id || '') !== String(offerId || '')) {
|
||||
return res.status(409).json({ error: 'Ihr Angebot hat sich geändert. Bitte laden Sie die Seite neu.' })
|
||||
}
|
||||
if (!isStripeConfigured() || !isPilotAllowed(customer, offer)) {
|
||||
return res.status(403).json({ error: 'Die Online-Bestellung ist noch nicht freigeschaltet.' })
|
||||
}
|
||||
// Nachweis der Zustimmung (§ 305 Abs. 2, § 357a Abs. 2 BGB): Zeitpunkt, Version, Wahl, IP, Browser
|
||||
const now = new Date().toISOString()
|
||||
const url = await createAboCheckout(customer, {
|
||||
termsAcceptedAt: now,
|
||||
startChoice,
|
||||
earlyStartConsentAt: startChoice === 'sofort' ? now : '',
|
||||
consentIp: req.ip,
|
||||
consentUserAgent: req.get('user-agent') || '',
|
||||
})
|
||||
const url = await createAboCheckout(
|
||||
customer,
|
||||
{
|
||||
termsAcceptedAt: now,
|
||||
startChoice,
|
||||
earlyStartConsentAt: startChoice === 'sofort' ? now : '',
|
||||
consentIp: req.ip,
|
||||
consentUserAgent: req.get('user-agent') || '',
|
||||
},
|
||||
offer ? { id: offer.$id, stripeCouponId: offer.stripeCouponId, text: offerText(offer) } : null
|
||||
)
|
||||
return res.json({ url })
|
||||
} catch (err) {
|
||||
console.error('[abo] Checkout fehlgeschlagen:', err.message)
|
||||
@@ -179,6 +196,7 @@ async function sendContractConfirmation(subDoc) {
|
||||
'',
|
||||
' Vertrag: WEBklar Wartungs-Abo',
|
||||
' Preis: 50,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer)',
|
||||
...(subDoc.offerText ? [` Angebot: ${subDoc.offerText} (wie vereinbart)`] : []),
|
||||
` Vertragsschluss: ${fmtDate(subDoc.startedAt)}`,
|
||||
` Leistungsbeginn: ${lateStart ? fmtDate(subDoc.trialEnd) : fmtDate(subDoc.startedAt)}`,
|
||||
...(minTermEndOf(subDoc)
|
||||
@@ -250,10 +268,19 @@ async function handleStripeEvent(event) {
|
||||
case 'checkout.session.completed': {
|
||||
if (obj.mode !== 'subscription' || !obj.subscription) return
|
||||
const sub = await syncSubscription(obj.subscription)
|
||||
const offerProblem = await activateOfferFromCheckout(obj).then(
|
||||
() => '',
|
||||
(e) => {
|
||||
console.error('[abo] Angebot konnte nicht aktiviert werden:', e.message)
|
||||
return `ACHTUNG: Angebot konnte nicht automatisch angewendet werden (${e.message}) -> Rabatt in Stripe am Abo prüfen!`
|
||||
}
|
||||
)
|
||||
await sendAdminNotice(`Neues Wartungs-Abo: ${sub?.email || obj.customer_details?.email}`, [
|
||||
`Kunde: ${obj.customer_details?.name || ''} <${obj.customer_details?.email || ''}>`,
|
||||
`Appwrite-Kunde: ${sub?.customerId || obj.client_reference_id}`,
|
||||
`Stripe-Abo: ${obj.subscription}`,
|
||||
...(obj.metadata?.offerText ? [`Angebot: ${obj.metadata.offerText}`] : []),
|
||||
...(offerProblem ? [offerProblem] : []),
|
||||
])
|
||||
await sendContractConfirmation(sub).catch((e) =>
|
||||
console.error('[abo] Vertragsbestaetigung fehlgeschlagen:', e.message)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { listDocuments, Query } from '../services/appwriteAdmin.js'
|
||||
import { adminOfferView, createOffer, listOffers, offerContext, withdrawOffer } from '../services/offers.js'
|
||||
|
||||
/**
|
||||
* Individuelle Abo-Angebote im Admin-Panel (Tab "Abo-Angebote").
|
||||
* Eingehaengt in routes/portalAdmin.js unter /api/portal-admin/offers, dort hinter requireAdminSession.
|
||||
*/
|
||||
export const offersAdminRouter = Router()
|
||||
|
||||
const adminOf = (req) => ({
|
||||
name: req.session.adminName || req.session.name,
|
||||
email: req.session.adminEmail || req.session.email,
|
||||
})
|
||||
|
||||
function sendError(res, err, fallback) {
|
||||
const status = err.status || 500
|
||||
if (status >= 500) console.error('[offers]', fallback, err.message)
|
||||
return res.status(status).json({ error: err.message || fallback })
|
||||
}
|
||||
|
||||
offersAdminRouter.get('/', async (_req, res) => {
|
||||
try {
|
||||
const [docs, customers] = await Promise.all([
|
||||
listOffers(),
|
||||
listDocuments(config.collections.customers, [Query.limit(1000)]),
|
||||
])
|
||||
const byId = new Map(customers.map((c) => [c.$id, c]))
|
||||
return res.json({ offers: docs.map((d) => adminOfferView(d, byId.get(d.customerId))) })
|
||||
} catch (err) {
|
||||
return sendError(res, err, 'Angebote konnten nicht geladen werden')
|
||||
}
|
||||
})
|
||||
|
||||
offersAdminRouter.get('/context/:customerId', async (req, res) => {
|
||||
try {
|
||||
return res.json(await offerContext(req.params.customerId))
|
||||
} catch (err) {
|
||||
return sendError(res, err, 'Kunde konnte nicht geladen werden')
|
||||
}
|
||||
})
|
||||
|
||||
offersAdminRouter.post('/', async (req, res) => {
|
||||
try {
|
||||
const { doc, customer, mail } = await createOffer(req.body || {}, adminOf(req))
|
||||
return res.status(201).json({
|
||||
offer: adminOfferView(doc, customer),
|
||||
mailSent: mail.sent,
|
||||
mailError: mail.sent ? '' : mail.reason || '',
|
||||
})
|
||||
} catch (err) {
|
||||
return sendError(res, err, 'Angebot konnte nicht angelegt werden')
|
||||
}
|
||||
})
|
||||
|
||||
offersAdminRouter.post('/:id/withdraw', async (req, res) => {
|
||||
try {
|
||||
const doc = await withdrawOffer(req.params.id, adminOf(req))
|
||||
return res.json({ success: true, status: doc.status })
|
||||
} catch (err) {
|
||||
return sendError(res, err, 'Angebot konnte nicht zurückgezogen werden')
|
||||
}
|
||||
})
|
||||
@@ -17,6 +17,7 @@ import { listAppointments, saveAppointment } from '../services/appointments.js'
|
||||
import { getCustomerActivity } from '../services/customerActivity.js'
|
||||
import { listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||
import { sanitizeMessage } from './chat.js'
|
||||
import { offersAdminRouter } from './offers.js'
|
||||
import { takeoverAdminRouter } from './takeoverCredits.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -25,6 +26,8 @@ router.use(requireAdminSession)
|
||||
|
||||
// Uebernahmegutschriften: Pruefung und Entscheidung (Tab "Gutschriften")
|
||||
router.use('/takeover-credits', takeoverAdminRouter)
|
||||
// Individuelle Abo-Angebote (Tab "Abo-Angebote")
|
||||
router.use('/offers', offersAdminRouter)
|
||||
|
||||
function norm(value) {
|
||||
return String(value || '').toLowerCase()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -175,6 +175,8 @@ export async function syncSubscription(subscriptionId) {
|
||||
consentIp: meta.consentIp || null,
|
||||
consentUserAgent: meta.consentUserAgent || null,
|
||||
consentTextVersion: meta.consentTextVersion || null,
|
||||
offerId: meta.offerId || null,
|
||||
offerText: meta.offerText || null,
|
||||
})
|
||||
return doc
|
||||
}
|
||||
@@ -243,9 +245,10 @@ export async function grantCreditForInvoice(invoice) {
|
||||
const ref = subscriptionIdOfInvoice(invoice)
|
||||
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
|
||||
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
|
||||
// total statt amount_paid: Eine Monatsgebuehr, die ein Guthaben (Uebernahmegutschrift) deckt,
|
||||
// ist bezahlt, obwohl nichts abgebucht wurde. Nur Probezeit-Rechnungen (0 €) zaehlen nicht.
|
||||
if (!(invoice.total > 0)) return { skipped: 'Betrag 0' }
|
||||
// subtotal (vor Rabatt und Guthaben) statt amount_paid: Auch ein Monat, den ein Angebot oder eine
|
||||
// Uebernahmegutschrift ganz deckt, ist ein bezahlter Leistungsmonat. Nur Probezeit-Rechnungen
|
||||
// haben eine Zwischensumme von 0 € und bringen keine Stunde.
|
||||
if (!(invoice.subtotal > 0)) return { skipped: 'Betrag 0' }
|
||||
|
||||
const sub = await syncSubscription(subscriptionId)
|
||||
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
|
||||
@@ -327,8 +330,10 @@ async function ensureTestCoupon() {
|
||||
* abgegeben ("Zahlungspflichtig bestellen"); hier hinterlegt er die Zahlungsart.
|
||||
* startChoice: 'sofort' (Leistung und erste Abbuchung sofort) oder 'nach_frist'
|
||||
* (15 Tage Probezeit ohne Leistung und ohne Abbuchung, Start nach der Widerrufsfrist).
|
||||
* offer: individuelles Angebot { id, stripeCouponId, text } -> Coupon im Checkout; bei Testkonten
|
||||
* bleibt der Testrabatt im Checkout und das Angebot wird nach dem Abschluss angehaengt (offerDeferred).
|
||||
*/
|
||||
export async function createAboCheckout(customer, consent) {
|
||||
export async function createAboCheckout(customer, consent, offer = null) {
|
||||
const current = await getCurrentSubscription(customer.$id)
|
||||
if (current && ACTIVE_STATUSES.has(current.status)) {
|
||||
const err = new Error('Sie haben das Wartungs-Abo bereits abgeschlossen.')
|
||||
@@ -354,7 +359,13 @@ export async function createAboCheckout(customer, consent) {
|
||||
earlyStartConsentAt: consent.earlyStartConsentAt || '',
|
||||
consentIp: String(consent.consentIp || '').slice(0, 64),
|
||||
consentUserAgent: String(consent.consentUserAgent || '').slice(0, 300),
|
||||
...(offer
|
||||
? { offerId: offer.id, offerText: offer.text.slice(0, 300), ...(testCoupon ? { offerDeferred: '1' } : {}) }
|
||||
: {}),
|
||||
}
|
||||
// Checkout erlaubt nur einen Rabatt: Testkonto -> Testrabatt, sonst der Angebots-Coupon
|
||||
const coupon = testCoupon || offer?.stripeCouponId || null
|
||||
const offerPrice = offer ? ` Preis wie vereinbart: ${offer.text}.` : ''
|
||||
const params = {
|
||||
mode: 'subscription',
|
||||
customer: stripeCustomerId,
|
||||
@@ -370,12 +381,14 @@ export async function createAboCheckout(customer, consent) {
|
||||
...(lateStart ? { trial_period_days: config.abo.lateStartTrialDays } : {}),
|
||||
},
|
||||
metadata: meta,
|
||||
...(testCoupon ? { discounts: [{ coupon: testCoupon }] } : {}),
|
||||
...(coupon ? { discounts: [{ coupon }] } : {}),
|
||||
custom_text: {
|
||||
submit: {
|
||||
message: lateStart
|
||||
? 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab. Danach 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.'
|
||||
: 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande. 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.',
|
||||
message: offer
|
||||
? `Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit ${lateStart ? 'dem Abschluss' : 'der ersten Zahlung'} kommt der Vertrag zustande.${lateStart ? ' Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab.' : ''}${offerPrice} Mindestlaufzeit 12 Monate, danach monatlich kündbar.`
|
||||
: lateStart
|
||||
? 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab. Danach 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.'
|
||||
: 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande. 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.',
|
||||
},
|
||||
},
|
||||
success_url: `${config.publicBaseUrl}/dashboard.html?abo=success`,
|
||||
|
||||
Reference in new issue
Block a user