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

+41 -14
View File
@@ -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)
+64
View File
@@ -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')
}
})
+3
View File
@@ -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()