Übernahmegutschrift: Der Kunde hat Domain, Hosting oder E-Mail beim bisherigen Anbieter schon bezahlt. Nach der Prüfung wird der doppelt bezahlte Anteil als Guthaben in Stripe gebucht und mit den nächsten Abo-Rechnungen verrechnet.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Angelegt
Kunde
Angebot
Gültig bis
Status
+
+
Lade…
+
+
+
Ein Angebot legt sofort einen einmal einlösbaren Coupon in Stripe an. Ohne laufendes Abo wird er beim Abschluss angewendet (und schaltet die Bestellung für diesen Kunden frei), bei laufendem Abo gilt er ab der nächsten Abbuchung. Nach den Angebotsmonaten gilt automatisch wieder der reguläre Preis.
${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}
Wartungs-Abo
-
50 €pro Monat · 12 Monate Mindestlaufzeit
+ ${offer
+ ? `
${offerPrice}${offer.months === 1 ? 'im ersten Monat' : `pro Monat in den ersten ${offer.months} Monaten`}, danach 50 € · 12 Monate Mindestlaufzeit
`
+ : '
50 €pro Monat · 12 Monate Mindestlaufzeit
'}
+ ${offer ? `
+
+ Ihr persönliches Angebot
+
${escapeHtml(offer.text)}. Sie sparen ${fmtEuro(offer.savingsCents / 100)}.
+ ${offer.note ? `
${escapeHtml(offer.note)}
` : ''}
+
Gültig bis ${fmtDateShort(offer.validUntil)} · wird beim Abschluss automatisch berücksichtigt
+
` : ''}
${aboFeaturesHtml()}
${available ? '
Schon bei einem anderen Anbieter für Domain, Hosting oder E-Mail bezahlt? Nach dem Abschluss können Sie eine Übernahmegutschrift beantragen – wir rechnen die doppelt bezahlte Zeit anteilig an.
Testkonto: Bei Stripe wird ein Test-Rabatt verrechnet, abgebucht werden nur 0,50 € pro Monat.
' : ''}
+ ${data.testAccount ? `
Testkonto: Bei Stripe wird ein Test-Rabatt verrechnet, abgebucht werden nur 0,50 € pro Monat.${offer ? ' Das Angebot wird nach dem Abschluss zusätzlich ans Abo gehängt.' : ''}
WEBklar Wartungs-Abo: Betrieb & Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach (5 GB), 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)
-
Preis
50,00 € pro Monat (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten
+
Preis
${offer
+ ? `${escapeHtml(offer.text)} (individuelles Angebot; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten`
+ : '50,00 € pro Monat (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten'}
Abrechnung
monatlich im Voraus, erstmals zum Leistungsbeginn
Laufzeit
Mindestlaufzeit 12 Monate ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach unbefristet und jederzeit zum Ende des Abrechnungsmonats kündbar
Jeden bezahlten Monat kommt 1 Stunde dazu. Angerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst.
Aufträge schicken Sie uns einfach über den Chat.${sub.minTermEnd && !sub.cancelAtPeriodEnd ? ` Mindestlaufzeit bis ${fmtDateShort(sub.minTermEnd)}, danach monatlich kündbar.` : ''}
+ ${sub.offerText ? `
Ihr Angebot: ${escapeHtml(sub.offerText)}.
` : ''}
${sub.withdrawalOpen ? `Vertrag widerrufenmöglich bis ${fmtDateShort(sub.withdrawalUntil)}` : ''}
diff --git a/public/dashboard.html b/public/dashboard.html
index 98bad5b..3daee70 100644
--- a/public/dashboard.html
+++ b/public/dashboard.html
@@ -157,6 +157,9 @@
.abo-summary td:last-child { text-align: left; }
.abo-withdraw { color: #fff; font-weight: 700; padding: 9px 16px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.6); text-decoration: none; }
.abo-withdraw:hover { background: #fff; color: #1c1c1c; }
+ /* Individuelles Angebot */
+ .abo-offer-box { margin: 16px 0 4px; padding: 14px 16px; border-radius: 14px; background: rgba(74,222,128,0.10); border: 1px solid rgba(74,222,128,0.45); }
+ .abo-offer-box p { margin: 4px 0 0; }
/* Übernahmegutschrift */
.pill-amber { background: rgba(251,191,36,0.15); border-color: rgba(251,191,36,0.45); }
diff --git a/scripts/setup-abo-collections.mjs b/scripts/setup-abo-collections.mjs
index 0b820f2..a5d21ee 100644
--- a/scripts/setup-abo-collections.mjs
+++ b/scripts/setup-abo-collections.mjs
@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
- * Wartungs-Abo: legt die Collections subscriptions, hourCredits und takeoverCredits
- * (Uebernahmegutschriften) in woms-database an und ergaenzt worksheets um das Flag
- * countsToPlan ("zaehlt aufs Abo-Stundenkonto").
+ * Wartungs-Abo: legt die Collections subscriptions, hourCredits, takeoverCredits
+ * (Uebernahmegutschriften) und aboOffers (individuelle Angebote) in woms-database an und
+ * ergaenzt worksheets um das Flag countsToPlan ("zaehlt aufs Abo-Stundenkonto").
* Idempotent: existierende Collection/Attribute/Indexe werden uebersprungen.
* Usage (im Container): docker exec -w /app webklar-kundenbereich-server node scripts/setup-abo-collections.mjs
*/
@@ -49,8 +49,10 @@ async function ensure(label, fn) {
}
async function waitForAttributes(collectionId, keys, maxSec = 60) {
+ // Ohne Limit liefert Appwrite nur 25 Attribute -> spaetere Attribute waeren nie "verfuegbar"
+ const limit = encodeURIComponent(JSON.stringify({ method: 'limit', values: [100] }))
for (let i = 0; i < maxSec; i += 1) {
- const { attributes } = await aw(`/databases/${databaseId}/collections/${collectionId}/attributes`)
+ const { attributes } = await aw(`/databases/${databaseId}/collections/${collectionId}/attributes?queries[]=${limit}`)
const pending = keys.filter((k) => attributes.find((a) => a.key === k)?.status !== 'available')
if (!pending.length) return
await new Promise((r) => setTimeout(r, 1000))
@@ -112,6 +114,9 @@ await ensureCollection(
{ type: 'string', key: 'consentTextVersion', size: 32 },
// v3: Beendigungszeitpunkt (Mindestlaufzeit / Wunschtermin)
{ type: 'datetime', key: 'cancelAt' },
+ // Individuelles Angebot, mit dem das Abo abgeschlossen wurde (Nachweis der Preisabrede)
+ { type: 'string', key: 'offerId', size: 64 },
+ { type: 'string', key: 'offerText', size: 300 },
],
[
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
@@ -173,6 +178,34 @@ await ensureCollection(
]
)
+// Individuelle Abo-Angebote: Admin legt vorab fest (z. B. erste 2 Monate je 25 €), Stripe-Coupon je Angebot
+await ensureCollection(
+ 'aboOffers',
+ 'Abo-Angebote',
+ [
+ { type: 'string', key: 'customerId', size: 64 },
+ { type: 'string', key: 'status', size: 20 },
+ { type: 'string', key: 'scope', size: 16 },
+ { type: 'integer', key: 'monthlyPriceCents' },
+ { type: 'integer', key: 'regularPriceCents' },
+ { type: 'integer', key: 'months' },
+ { type: 'datetime', key: 'validUntil' },
+ { type: 'string', key: 'note', size: 500 },
+ { type: 'string', key: 'internalNote', size: 2000 },
+ { type: 'string', key: 'stripeCouponId', size: 64 },
+ { type: 'string', key: 'stripeSubscriptionId', size: 64 },
+ { type: 'datetime', key: 'appliedAt' },
+ { type: 'string', key: 'createdBy', size: 255 },
+ { type: 'datetime', key: 'withdrawnAt' },
+ { type: 'string', key: 'withdrawnBy', size: 255 },
+ { type: 'datetime', key: 'notifiedAt' },
+ ],
+ [
+ { key: 'idx_customer', type: 'key', attributes: ['customerId'] },
+ { key: 'idx_status', type: 'key', attributes: ['status'] },
+ ]
+)
+
// Worksheet-Flag: nur markierte Arbeitszeit wird vom Abo-Guthaben abgezogen
await ensure('worksheets.countsToPlan', () =>
aw(`${col('worksheets')}/attributes/boolean`, {
diff --git a/server/config.js b/server/config.js
index 7d06f75..3a6847b 100644
--- a/server/config.js
+++ b/server/config.js
@@ -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)
diff --git a/server/routes/abo.js b/server/routes/abo.js
index d55c641..82a3a3b 100644
--- a/server/routes/abo.js
+++ b/server/routes/abo.js
@@ -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)
diff --git a/server/routes/offers.js b/server/routes/offers.js
new file mode 100644
index 0000000..d3a72ba
--- /dev/null
+++ b/server/routes/offers.js
@@ -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')
+ }
+})
diff --git a/server/routes/portalAdmin.js b/server/routes/portalAdmin.js
index 4bcb1b9..86f81e6 100644
--- a/server/routes/portalAdmin.js
+++ b/server/routes/portalAdmin.js
@@ -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()
diff --git a/server/services/offers.js b/server/services/offers.js
new file mode 100644
index 0000000..fa3261a
--- /dev/null
+++ b/server/services/offers.js
@@ -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,
+ })
+}
diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js
index e0f6901..39e8fbf 100644
--- a/server/services/stripeBilling.js
+++ b/server/services/stripeBilling.js
@@ -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`,