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
+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()
|
||||
|
||||
Reference in new issue
Block a user