Wartungs-Abo (50 EUR/Monat) ueber Stripe mit Stundenkonto im Portal
- Bestellung auf der Portal-Startseite: Zusammenfassung, AGB- und Vorab-Leistungs-Zustimmung, Button 'Zahlungspflichtig bestellen', danach Stripe Checkout (Abo, Kleinunternehmer-Fusszeile) - Stripe-Webhook /api/abo/webhook spiegelt Abos nach Appwrite (subscriptions) und schreibt je bezahlter Rechnung 60 min gut (hourCredits, idempotent ueber Invoice-ID, 12 Monate gueltig) - Stundenkonto: FIFO-Verbrauch aus Worksheets mit countsToPlan, 15-Minuten-Takt, Mehraufwand separat ausgewiesen - Stripe-Kundenportal fuer Zahlungsart/Rechnungen/Kuendigung - Oeffentlicher Kuendigungsbutton (§ 312k) und Widerrufsbutton (§ 356a) mit Eingangsbestaetigung per Mail - Abo-Bedingungen inkl. Leistungsbeschreibung und Widerrufsbelehrung, als Anhang der Vertragsbestaetigung Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c3f56ba290
commit
65429aa3ac
17 files changed
+1677
-2
No files matched your search
@@ -47,6 +47,30 @@ export const config = {
|
||||
worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets',
|
||||
employees: process.env.APPWRITE_COLLECTION_EMPLOYEES || 'employees',
|
||||
portalMessages: process.env.APPWRITE_COLLECTION_PORTAL_MESSAGES || 'portalMessages',
|
||||
subscriptions: process.env.APPWRITE_COLLECTION_SUBSCRIPTIONS || 'subscriptions',
|
||||
hourCredits: process.env.APPWRITE_COLLECTION_HOUR_CREDITS || 'hourCredits',
|
||||
},
|
||||
|
||||
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
|
||||
|
||||
// Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an.
|
||||
stripe: {
|
||||
secretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
priceLookupKey: process.env.STRIPE_ABO_PRICE_LOOKUP_KEY || 'webklar_wartung_monatlich_5000',
|
||||
priceAmountCents: 5000,
|
||||
productName: 'Webklar Wartungs-Abo',
|
||||
// Kleinunternehmer: Pflichthinweis auf jeder Stripe-Rechnung
|
||||
invoiceFooter:
|
||||
process.env.STRIPE_INVOICE_FOOTER ||
|
||||
'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung). WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler',
|
||||
},
|
||||
|
||||
abo: {
|
||||
termsVersion: '2026-09-25',
|
||||
minutesPerMonth: 60,
|
||||
creditValidityMonths: 12,
|
||||
usageRoundingMinutes: 15,
|
||||
},
|
||||
|
||||
smtp: {
|
||||
|
||||
@@ -11,6 +11,7 @@ import featuresRoutes from './routes/features.js'
|
||||
import customerDataRoutes from './routes/customerData.js'
|
||||
import portalAdminRoutes from './routes/portalAdmin.js'
|
||||
import chatRoutes from './routes/chat.js'
|
||||
import aboRoutes from './routes/abo.js'
|
||||
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
||||
import adminGiteaRoutes from './routes/admin/gitea.js'
|
||||
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
|
||||
@@ -48,6 +49,7 @@ app.use('/api/projects', projectsRoutes)
|
||||
app.use('/api/features', featuresRoutes)
|
||||
app.use('/api', customerDataRoutes)
|
||||
app.use('/api/chat', chatRoutes)
|
||||
app.use('/api/abo', aboRoutes)
|
||||
app.use('/api/portal-admin', portalAdminRoutes)
|
||||
app.use('/api/admin/gitea', adminGiteaRoutes)
|
||||
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
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 {
|
||||
ACTIVE_STATUSES,
|
||||
cancelAtPeriodEnd,
|
||||
constructWebhookEvent,
|
||||
createAboCheckout,
|
||||
createBillingPortalUrl,
|
||||
getCurrentSubscription,
|
||||
grantCreditForInvoice,
|
||||
isStripeConfigured,
|
||||
syncSubscription,
|
||||
} from '../services/stripeBilling.js'
|
||||
|
||||
const router = Router()
|
||||
const publicDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public')
|
||||
|
||||
const fmtDate = (iso) =>
|
||||
iso ? new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }) : '–'
|
||||
const fmtDateTime = (d) =>
|
||||
new Date(d).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' })
|
||||
|
||||
function publicSubscription(sub) {
|
||||
if (!sub) return null
|
||||
return {
|
||||
status: sub.status,
|
||||
active: ACTIVE_STATUSES.has(sub.status),
|
||||
paymentProblem: sub.status === 'past_due' || sub.status === 'unpaid',
|
||||
startedAt: sub.startedAt,
|
||||
currentPeriodEnd: sub.currentPeriodEnd,
|
||||
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd),
|
||||
endedAt: sub.endedAt,
|
||||
priceAmount: sub.priceAmount,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kunde (eingeloggt)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Abo-Status + Stundenkonto fuer die Startseite. */
|
||||
router.get('/', requireSession, async (req, res) => {
|
||||
try {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
const sub = await getCurrentSubscription(customerId)
|
||||
const active = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
|
||||
const ledger = sub ? await getLedger(customerId, { active }) : null
|
||||
return res.json({
|
||||
available: isStripeConfigured(),
|
||||
termsVersion: config.abo.termsVersion,
|
||||
subscription: publicSubscription(sub),
|
||||
ledger,
|
||||
})
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Abo konnte nicht geladen werden' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Bestellung: Zustimmungen pruefen, dann Stripe Checkout (nur Zahlungsart). */
|
||||
router.post('/checkout', requireSession, async (req, res) => {
|
||||
try {
|
||||
if (req.session.viewAs) {
|
||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' })
|
||||
}
|
||||
const { acceptTerms, earlyStart, termsVersion } = req.body || {}
|
||||
if (acceptTerms !== true || earlyStart !== true) {
|
||||
return res.status(400).json({ error: 'Bitte bestätigen Sie beide Punkte, um zu bestellen.' })
|
||||
}
|
||||
if (termsVersion !== config.abo.termsVersion) {
|
||||
return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
|
||||
}
|
||||
const customer = await getDocument(config.collections.customers, getSessionCustomerId(req))
|
||||
const now = new Date().toISOString()
|
||||
const url = await createAboCheckout(customer, { termsAcceptedAt: now, earlyStartConsentAt: now })
|
||||
return res.json({ url })
|
||||
} catch (err) {
|
||||
console.error('[abo] Checkout fehlgeschlagen:', err.message)
|
||||
return res.status(err.status || 500).json({ error: err.message || 'Bestellung fehlgeschlagen' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Stripe-Kundenportal: Zahlungsart, Rechnungen, Kuendigung. */
|
||||
router.post('/portal', requireSession, async (req, res) => {
|
||||
try {
|
||||
if (req.session.viewAs) {
|
||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
|
||||
}
|
||||
const url = await createBillingPortalUrl(getSessionCustomerId(req))
|
||||
return res.json({ url })
|
||||
} catch (err) {
|
||||
return res.status(err.status || 500).json({ error: err.message || 'Portal nicht verfügbar' })
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stripe-Webhook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function sendContractConfirmation(subDoc) {
|
||||
if (!subDoc || subDoc.confirmationSentAt || !subDoc.email) return
|
||||
const terms = await readFile(path.join(publicDir, 'abo-bedingungen.html'), 'utf8').catch(() => null)
|
||||
await sendCustomerMail(
|
||||
subDoc.email,
|
||||
'Ihr Webklar Wartungs-Abo – Vertragsbestätigung',
|
||||
[
|
||||
'Guten Tag,',
|
||||
'',
|
||||
'vielen Dank für Ihre Bestellung. Hiermit bestätigen wir den Abschluss Ihres Wartungs-Abos:',
|
||||
'',
|
||||
' Vertrag: Webklar Wartungs-Abo',
|
||||
' Preis: 50,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer)',
|
||||
` Vertragsbeginn: ${fmtDate(subDoc.startedAt)}`,
|
||||
' Laufzeit: unbestimmt, monatlich zum Ende des bezahlten Monats kündbar',
|
||||
' Enthalten: Betrieb & Pflege Ihrer Website/Anwendung, 1 Domain, 1 E-Mail-Postfach,',
|
||||
' 1 Stunde Arbeitszeit pro Monat (je 12 Monate gültig)',
|
||||
'',
|
||||
`Sie haben am ${fmtDateTime(subDoc.termsAcceptedAt || Date.now())} den Abo-Bedingungen (Stand ${subDoc.termsVersion || config.abo.termsVersion}) zugestimmt`,
|
||||
'und ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit der Leistung beginnen.',
|
||||
'Bei einem Widerruf schulden Sie Wertersatz für die bis dahin erbrachten Leistungen (§ 357a Abs. 2 BGB).',
|
||||
'',
|
||||
'Die vollständigen Abo-Bedingungen inkl. Leistungsbeschreibung und Widerrufsbelehrung finden Sie im Anhang',
|
||||
`sowie unter ${config.publicBaseUrl}/abo-bedingungen.html`,
|
||||
'',
|
||||
`Ihr Stundenkonto sehen Sie jederzeit im Kundenportal: ${config.publicBaseUrl}/dashboard.html`,
|
||||
`Kündigen: ${config.publicBaseUrl}/kuendigen.html · Widerrufen: ${config.publicBaseUrl}/widerrufen.html`,
|
||||
'',
|
||||
'Viele Grüße',
|
||||
'Ihr Webklar-Team',
|
||||
'',
|
||||
'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · kenso.gri@gmail.com',
|
||||
],
|
||||
terms
|
||||
? [{ filename: `Webklar-Abo-Bedingungen-${config.abo.termsVersion}.html`, content: terms, contentType: 'text/html' }]
|
||||
: []
|
||||
)
|
||||
await updateDocument(config.collections.subscriptions, subDoc.$id, {
|
||||
confirmationSentAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
async function handleStripeEvent(event) {
|
||||
const obj = event.data.object
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
if (obj.mode !== 'subscription' || !obj.subscription) return
|
||||
const sub = await syncSubscription(obj.subscription)
|
||||
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}`,
|
||||
])
|
||||
await sendContractConfirmation(sub).catch((e) =>
|
||||
console.error('[abo] Vertragsbestaetigung fehlgeschlagen:', e.message)
|
||||
)
|
||||
return
|
||||
}
|
||||
case 'invoice.paid': {
|
||||
const result = await grantCreditForInvoice(obj)
|
||||
if (result.granted) {
|
||||
console.log(`[abo] 60 min gutgeschrieben: Kunde ${result.customerId}, Rechnung ${obj.id}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'invoice.payment_failed': {
|
||||
const ref = obj.parent?.subscription_details?.subscription || obj.subscription
|
||||
const sub = ref ? await syncSubscription(typeof ref === 'string' ? ref : ref.id) : null
|
||||
await sendAdminNotice(`Zahlung fehlgeschlagen: ${sub?.email || obj.customer_email || ''}`, [
|
||||
`Rechnung ${obj.number || obj.id}, Betrag ${(obj.amount_due / 100).toFixed(2)} €.`,
|
||||
'Stripe versucht die Zahlung automatisch erneut und mahnt per E-Mail.',
|
||||
])
|
||||
return
|
||||
}
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated':
|
||||
await syncSubscription(obj.id)
|
||||
return
|
||||
case 'customer.subscription.deleted': {
|
||||
const sub = await syncSubscription(obj.id)
|
||||
await sendAdminNotice(`Abo beendet: ${sub?.email || ''}`, [
|
||||
`Stripe-Abo ${obj.id} ist beendet (Status ${obj.status}). Restguthaben verfällt.`,
|
||||
'Hosting/Domain/Postfach gemäß Ziff. 9 der Abo-Bedingungen abwickeln (Export, AuthCode).',
|
||||
])
|
||||
return
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/webhook', async (req, res) => {
|
||||
let event
|
||||
try {
|
||||
event = constructWebhookEvent(req.rawBody, req.get('stripe-signature'))
|
||||
} catch (err) {
|
||||
console.warn('[abo] Webhook-Signatur ungueltig:', err.message)
|
||||
return res.status(400).send(`Webhook Error: ${err.message}`)
|
||||
}
|
||||
try {
|
||||
await handleStripeEvent(event)
|
||||
return res.json({ received: true })
|
||||
} catch (err) {
|
||||
// 500 -> Stripe wiederholt das Event (Handler sind idempotent)
|
||||
console.error(`[abo] Webhook ${event.type} fehlgeschlagen:`, err.message)
|
||||
return res.status(500).json({ error: 'processing failed' })
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Oeffentlich: Kuendigungsbutton (§ 312k BGB) + Widerrufsbutton (§ 356a BGB)
|
||||
// Ohne Login erreichbar; Bestaetigung geht an die angegebene E-Mail-Adresse.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const recentRequests = new Map() // ip -> [timestamps]
|
||||
function rateLimited(ip) {
|
||||
const now = Date.now()
|
||||
const list = (recentRequests.get(ip) || []).filter((t) => now - t < 60 * 60 * 1000)
|
||||
list.push(now)
|
||||
recentRequests.set(ip, list)
|
||||
return list.length > 10
|
||||
}
|
||||
|
||||
function validDeclaration(body) {
|
||||
const name = String(body?.name || '').trim().slice(0, 200)
|
||||
const email = String(body?.email || '').trim().slice(0, 255)
|
||||
if (!name || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null
|
||||
return {
|
||||
name,
|
||||
email,
|
||||
customerNumber: String(body?.customerNumber || '').trim().slice(0, 50),
|
||||
kind: body?.kind === 'ausserordentlich' ? 'ausserordentlich' : 'ordentlich',
|
||||
reason: String(body?.reason || '').trim().slice(0, 2000),
|
||||
effective: String(body?.effective || '').trim().slice(0, 40),
|
||||
}
|
||||
}
|
||||
|
||||
async function findActiveSubscriptionByEmail(email) {
|
||||
const customer =
|
||||
(await getCustomerByEmail(email).catch(() => null)) ||
|
||||
(await getCustomerByEmail(email.toLowerCase()).catch(() => null))
|
||||
if (!customer) return null
|
||||
const sub = await getCurrentSubscription(customer.$id)
|
||||
return sub && ACTIVE_STATUSES.has(sub.status) ? sub : null
|
||||
}
|
||||
|
||||
router.post('/public/cancel', async (req, res) => {
|
||||
if (rateLimited(req.ip)) return res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' })
|
||||
const d = validDeclaration(req.body)
|
||||
if (!d) return res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' })
|
||||
const receivedAt = new Date()
|
||||
|
||||
let sub = null
|
||||
try {
|
||||
sub = await findActiveSubscriptionByEmail(d.email)
|
||||
if (sub) {
|
||||
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId)
|
||||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||||
cancelRequestedAt: receivedAt.toISOString(),
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[abo] Kuendigung automatisch nicht moeglich:', err.message)
|
||||
}
|
||||
|
||||
const effectiveText = sub
|
||||
? `Ihr Wartungs-Abo endet zum ${fmtDate(sub.currentPeriodEnd)} (Ende des bezahlten Monats).`
|
||||
: 'Wir ordnen Ihre Kündigung manuell Ihrem Vertrag zu und bestätigen Ihnen das Vertragsende gesondert.'
|
||||
|
||||
await sendCustomerMail(d.email, 'Eingangsbestätigung Ihrer Kündigung – Webklar Wartungs-Abo', [
|
||||
`Guten Tag ${d.name},`,
|
||||
'',
|
||||
`wir haben Ihre ${d.kind === 'ausserordentlich' ? 'außerordentliche' : 'ordentliche'} Kündigung am ${fmtDateTime(receivedAt)} erhalten.`,
|
||||
'',
|
||||
` Name: ${d.name}`,
|
||||
` E-Mail: ${d.email}`,
|
||||
d.customerNumber ? ` Kundennummer: ${d.customerNumber}` : null,
|
||||
' Vertrag: Webklar Wartungs-Abo',
|
||||
` Gewünschter Zeitpunkt: ${d.effective || 'nächstmöglicher Zeitpunkt'}`,
|
||||
d.reason ? ` Grund: ${d.reason}` : null,
|
||||
'',
|
||||
effectiveText,
|
||||
'Ein bestehendes Stundenguthaben verfällt mit Vertragsende. Ihre Daten, den Domain-AuthCode und',
|
||||
'Ihr Postfach stellen wir Ihnen auf Wunsch gemäß Ziff. 9 der Abo-Bedingungen zur Verfügung.',
|
||||
'',
|
||||
'Viele Grüße',
|
||||
'Ihr Webklar-Team',
|
||||
].filter((l) => l !== null)).catch((e) => console.error('[abo] Kuendigungsbestaetigung fehlgeschlagen:', e.message))
|
||||
|
||||
await sendAdminNotice(`Kündigung eingegangen: ${d.email}`, [
|
||||
`${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`,
|
||||
`Art: ${d.kind}, Zeitpunkt: ${d.effective || 'nächstmöglich'}`,
|
||||
d.reason ? `Grund: ${d.reason}` : '',
|
||||
sub
|
||||
? `Automatisch in Stripe zum ${fmtDate(sub.currentPeriodEnd)} gekündigt (${sub.stripeSubscriptionId}).`
|
||||
: 'KEIN aktives Abo zu dieser E-Mail gefunden -> bitte manuell zuordnen und Vertragsende bestätigen!',
|
||||
d.kind === 'ausserordentlich' ? 'Außerordentliche Kündigung -> Grund prüfen, ggf. sofort beenden.' : '',
|
||||
])
|
||||
|
||||
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
|
||||
})
|
||||
|
||||
router.post('/public/withdraw', async (req, res) => {
|
||||
if (rateLimited(req.ip)) return res.status(429).json({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' })
|
||||
const d = validDeclaration(req.body)
|
||||
if (!d) return res.status(400).json({ error: 'Bitte Name und eine gültige E-Mail-Adresse angeben.' })
|
||||
const receivedAt = new Date()
|
||||
|
||||
let sub = null
|
||||
try {
|
||||
sub = await findActiveSubscriptionByEmail(d.email)
|
||||
if (sub) {
|
||||
// Keine weiteren Abbuchungen; Wertersatz/Erstattung rechnet das Team ab
|
||||
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId)
|
||||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||||
withdrawalRequestedAt: receivedAt.toISOString(),
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[abo] Widerruf automatisch nicht verarbeitbar:', err.message)
|
||||
}
|
||||
|
||||
await sendCustomerMail(d.email, 'Eingangsbestätigung Ihres Widerrufs – Webklar Wartungs-Abo', [
|
||||
`Guten Tag ${d.name},`,
|
||||
'',
|
||||
`wir haben Ihren Widerruf des Vertrags „Webklar Wartungs-Abo" am ${fmtDateTime(receivedAt)} erhalten.`,
|
||||
'',
|
||||
` Name: ${d.name}`,
|
||||
` E-Mail: ${d.email}`,
|
||||
d.customerNumber ? ` Kundennummer: ${d.customerNumber}` : null,
|
||||
'',
|
||||
'Es erfolgen keine weiteren Abbuchungen. Bereits gezahlte Beträge erstatten wir innerhalb von 14 Tagen',
|
||||
'über das ursprüngliche Zahlungsmittel, abzüglich eines anteiligen Wertersatzes für die bis zum Widerruf',
|
||||
'erbrachten Leistungen (§ 357a Abs. 2 BGB). Sie erhalten dazu eine gesonderte Abrechnung.',
|
||||
'',
|
||||
'Viele Grüße',
|
||||
'Ihr Webklar-Team',
|
||||
].filter((l) => l !== null)).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
|
||||
|
||||
await sendAdminNotice(`WIDERRUF eingegangen: ${d.email}`, [
|
||||
`${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`,
|
||||
sub
|
||||
? `Stripe-Abo ${sub.stripeSubscriptionId} (Beginn ${fmtDate(sub.startedAt)}) auf Kündigung gesetzt.`
|
||||
: 'KEIN aktives Abo zu dieser E-Mail gefunden -> manuell zuordnen!',
|
||||
'TODO: Widerrufsfrist prüfen (14 Tage ab Vertragsschluss), Abo in Stripe sofort beenden,',
|
||||
'Wertersatz berechnen (anteilige Tage) und Rest innerhalb 14 Tagen erstatten (Stripe -> Rückerstattung).',
|
||||
])
|
||||
|
||||
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,131 @@
|
||||
import { config } from '../config.js'
|
||||
import { listDocuments, Query } from './appwriteAdmin.js'
|
||||
|
||||
/**
|
||||
* Abo-Stundenkonto eines Kunden.
|
||||
*
|
||||
* Gutschriften: je bezahlter Abo-Rechnung 60 min (Collection hourCredits), gueltig 12 Monate.
|
||||
* Verbrauch: Worksheets mit countsToPlan=true auf Tickets des Kunden, je Eintrag
|
||||
* auf 15 min aufgerundet.
|
||||
* Verrechnung: FIFO - die aelteste noch gueltige Gutschrift wird zuerst verbraucht.
|
||||
* Was kein Guthaben mehr findet, ist Mehraufwand (separat abzurechnen).
|
||||
* Vertragsende: Restguthaben verfaellt (siehe Abo-Bedingungen Ziff. 4).
|
||||
*/
|
||||
|
||||
function parseSheetDate(ws) {
|
||||
const raw = ws.startDate || ws.endDate || ''
|
||||
const m = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(raw)
|
||||
if (m) return new Date(`${m[3]}-${m[2]}-${m[1]}T12:00:00Z`)
|
||||
const d = new Date(raw || ws.createdAt || ws.$createdAt)
|
||||
return Number.isNaN(d.getTime()) ? new Date(ws.$createdAt) : d
|
||||
}
|
||||
|
||||
function roundUsage(minutes) {
|
||||
const step = config.abo.usageRoundingMinutes
|
||||
return Math.ceil(Math.max(0, Number(minutes) || 0) / step) * step
|
||||
}
|
||||
|
||||
export async function listCredits(customerId) {
|
||||
return listDocuments(config.collections.hourCredits, [
|
||||
Query.equal('customerId', customerId),
|
||||
Query.orderAsc('grantedAt'),
|
||||
Query.limit(1000),
|
||||
])
|
||||
}
|
||||
|
||||
export async function listPlanUsage(customerId) {
|
||||
const workorders = await listDocuments(config.collections.workorders, [
|
||||
Query.equal('customerId', customerId),
|
||||
Query.limit(5000),
|
||||
])
|
||||
if (!workorders.length) return []
|
||||
const byId = new Map(workorders.map((w) => [w.$id, w]))
|
||||
const ids = [...byId.keys()]
|
||||
const sheets = []
|
||||
// Appwrite begrenzt die Werte-Liste pro equal-Query -> in Bloecken abfragen
|
||||
for (let i = 0; i < ids.length; i += 100) {
|
||||
const chunk = await listDocuments(config.collections.worksheets, [
|
||||
Query.equal('workorderId', ids.slice(i, i + 100)),
|
||||
Query.equal('countsToPlan', true),
|
||||
Query.limit(5000),
|
||||
])
|
||||
sheets.push(...chunk)
|
||||
}
|
||||
return sheets
|
||||
.filter((ws) => Number(ws.totalTime) > 0)
|
||||
.map((ws) => {
|
||||
const wo = byId.get(ws.workorderId) || {}
|
||||
return {
|
||||
id: ws.$id,
|
||||
wsid: ws.wsid || '',
|
||||
woid: ws.woid || wo.woid || '',
|
||||
topic: wo.topic || wo.title || '',
|
||||
date: parseSheetDate(ws).toISOString(),
|
||||
rawMinutes: Number(ws.totalTime) || 0,
|
||||
minutes: roundUsage(ws.totalTime),
|
||||
details: String(ws.details || '').slice(0, 300),
|
||||
employee: ws.employeeName || ws.employeeShort || '',
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
}
|
||||
|
||||
/** Reine Berechnung (testbar ohne DB). */
|
||||
export function computeLedger(credits, usage, { now = new Date(), active = true } = {}) {
|
||||
const pool = credits
|
||||
.map((c) => ({
|
||||
id: c.$id,
|
||||
minutes: Number(c.minutes) || 0,
|
||||
remaining: Number(c.minutes) || 0,
|
||||
grantedAt: c.grantedAt,
|
||||
expiresAt: c.expiresAt,
|
||||
}))
|
||||
.sort((a, b) => String(a.grantedAt).localeCompare(String(b.grantedAt)))
|
||||
|
||||
let overageMinutes = 0
|
||||
const entries = usage.map((u) => {
|
||||
let need = u.minutes
|
||||
const at = new Date(u.date)
|
||||
for (const c of pool) {
|
||||
if (!need) break
|
||||
// Nur Gutschriften, die zum Arbeitszeitpunkt noch gueltig waren
|
||||
if (c.remaining <= 0 || new Date(c.expiresAt) <= at) continue
|
||||
const take = Math.min(need, c.remaining)
|
||||
c.remaining -= take
|
||||
need -= take
|
||||
}
|
||||
overageMinutes += need
|
||||
return { ...u, coveredMinutes: u.minutes - need, overageMinutes: need }
|
||||
})
|
||||
|
||||
const valid = pool.filter((c) => c.remaining > 0 && new Date(c.expiresAt) > now)
|
||||
const expiredMinutes = pool
|
||||
.filter((c) => c.remaining > 0 && new Date(c.expiresAt) <= now)
|
||||
.reduce((s, c) => s + c.remaining, 0)
|
||||
const balanceMinutes = active ? valid.reduce((s, c) => s + c.remaining, 0) : 0
|
||||
const nextExpiry = active && valid.length
|
||||
? valid.reduce((a, c) => (new Date(c.expiresAt) < new Date(a.expiresAt) ? c : a))
|
||||
: null
|
||||
|
||||
return {
|
||||
balanceMinutes,
|
||||
grantedMinutes: pool.reduce((s, c) => s + c.minutes, 0),
|
||||
usedMinutes: entries.reduce((s, e) => s + e.coveredMinutes, 0),
|
||||
overageMinutes,
|
||||
expiredMinutes,
|
||||
nextExpiry: nextExpiry ? { minutes: nextExpiry.remaining, at: nextExpiry.expiresAt } : null,
|
||||
credits: pool.map((c) => ({
|
||||
grantedAt: c.grantedAt,
|
||||
expiresAt: c.expiresAt,
|
||||
minutes: c.minutes,
|
||||
remaining: active && new Date(c.expiresAt) > now ? c.remaining : 0,
|
||||
expired: new Date(c.expiresAt) <= now,
|
||||
})),
|
||||
usage: entries.reverse(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLedger(customerId, { active = true } = {}) {
|
||||
const [credits, usage] = await Promise.all([listCredits(customerId), listPlanUsage(customerId)])
|
||||
return computeLedger(credits, usage, { active })
|
||||
}
|
||||
@@ -54,3 +54,42 @@ export async function sendChatNotification(customer, text) {
|
||||
return { sent: false, reason: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wartungs-Abo: Vertrags-/Kuendigungs-/Widerrufsbestaetigungen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Mail an die Webklar-Admins (Abo-Ereignisse). Fehler werden nur geloggt. */
|
||||
export async function sendAdminNotice(subject, lines) {
|
||||
try {
|
||||
const mailer = getTransporter()
|
||||
if (!mailer || !config.chatNotify.emails.length) return { sent: false, reason: 'nicht konfiguriert' }
|
||||
await mailer.sendMail({
|
||||
from: `"Webklar Kundenportal" <${config.smtp.from}>`,
|
||||
to: config.chatNotify.emails.join(', '),
|
||||
subject: `[Abo] ${subject}`,
|
||||
text: lines.join('\n'),
|
||||
})
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
console.error('[mailer] Admin-Hinweis fehlgeschlagen:', err.message)
|
||||
return { sent: false, reason: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mail an Kunden. `attachments` fuer die Vertragsbestaetigung auf dauerhaftem
|
||||
* Datentraeger (§ 312f BGB): Bedingungen als HTML-Anhang.
|
||||
*/
|
||||
export async function sendCustomerMail(to, subject, lines, attachments = []) {
|
||||
const mailer = getTransporter()
|
||||
if (!mailer) throw new Error('SMTP nicht konfiguriert')
|
||||
await mailer.sendMail({
|
||||
from: `"Webklar" <${config.smtp.from}>`,
|
||||
to,
|
||||
bcc: config.chatNotify.emails.join(', ') || undefined,
|
||||
subject,
|
||||
text: lines.join('\n'),
|
||||
attachments,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import Stripe from 'stripe'
|
||||
import { config } from '../config.js'
|
||||
import {
|
||||
createDocument,
|
||||
getDocument,
|
||||
listDocuments,
|
||||
Query,
|
||||
updateDocument,
|
||||
} from './appwriteAdmin.js'
|
||||
|
||||
/**
|
||||
* Wartungs-Abo ueber Stripe Billing.
|
||||
*
|
||||
* Wahrheit ueber den Abo-Status ist immer Stripe: jedes Webhook-Event laedt das
|
||||
* Subscription-Objekt frisch nach und spiegelt es in die Appwrite-Collection
|
||||
* `subscriptions` (Dokument-ID = Stripe-Subscription-ID -> Upsert ohne Duplikate).
|
||||
* Stundengutschriften haengen an bezahlten Rechnungen (Dokument-ID = Invoice-ID
|
||||
* -> ein Webhook-Retry kann nie doppelt gutschreiben).
|
||||
*/
|
||||
|
||||
// Status, in denen der Kunde das Abo "hat" (past_due = Zahlung fehlgeschlagen, Stripe versucht erneut)
|
||||
export const ACTIVE_STATUSES = new Set(['active', 'trialing', 'past_due'])
|
||||
|
||||
let stripeClient = null
|
||||
export function getStripe() {
|
||||
if (!config.stripe.secretKey) {
|
||||
const err = new Error('Zahlungen sind noch nicht eingerichtet (STRIPE_SECRET_KEY fehlt).')
|
||||
err.status = 503
|
||||
throw err
|
||||
}
|
||||
if (!stripeClient) {
|
||||
stripeClient = new Stripe(config.stripe.secretKey, { maxNetworkRetries: 2 })
|
||||
}
|
||||
return stripeClient
|
||||
}
|
||||
|
||||
export function isStripeConfigured() {
|
||||
return Boolean(config.stripe.secretKey)
|
||||
}
|
||||
|
||||
let cachedPriceId = null
|
||||
/** Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. */
|
||||
export async function ensureAboPrice() {
|
||||
if (cachedPriceId) return cachedPriceId
|
||||
const stripe = getStripe()
|
||||
const { data } = await stripe.prices.list({
|
||||
lookup_keys: [config.stripe.priceLookupKey],
|
||||
active: true,
|
||||
limit: 1,
|
||||
})
|
||||
if (data[0]) {
|
||||
cachedPriceId = data[0].id
|
||||
return cachedPriceId
|
||||
}
|
||||
const product = await stripe.products.create({
|
||||
name: config.stripe.productName,
|
||||
description:
|
||||
'Betrieb & Pflege Ihrer Website/Web-Anwendung, 1 Domain, 1 E-Mail-Postfach, 1 Stunde Arbeitszeit pro Monat (12 Monate gültig).',
|
||||
metadata: { webklar: 'wartungs-abo' },
|
||||
})
|
||||
const price = await stripe.prices.create({
|
||||
product: product.id,
|
||||
currency: 'eur',
|
||||
unit_amount: config.stripe.priceAmountCents,
|
||||
recurring: { interval: 'month' },
|
||||
tax_behavior: 'inclusive',
|
||||
lookup_key: config.stripe.priceLookupKey,
|
||||
nickname: 'Wartungs-Abo monatlich 50 EUR',
|
||||
})
|
||||
cachedPriceId = price.id
|
||||
return cachedPriceId
|
||||
}
|
||||
|
||||
let cachedPortalConfigId = null
|
||||
/** Kundenportal-Konfiguration (Zahlungsmittel, Rechnungen, Kuendigung zum Periodenende). */
|
||||
async function ensurePortalConfiguration() {
|
||||
if (cachedPortalConfigId) return cachedPortalConfigId
|
||||
const stripe = getStripe()
|
||||
const { data } = await stripe.billingPortal.configurations.list({ active: true, limit: 20 })
|
||||
const own = data.find((c) => c.metadata?.webklar === 'wartungs-abo')
|
||||
if (own) {
|
||||
cachedPortalConfigId = own.id
|
||||
return own.id
|
||||
}
|
||||
const created = await stripe.billingPortal.configurations.create({
|
||||
business_profile: {
|
||||
headline: 'Webklar – Wartungs-Abo verwalten',
|
||||
terms_of_service_url: `${config.publicBaseUrl}/abo-bedingungen.html`,
|
||||
},
|
||||
default_return_url: `${config.publicBaseUrl}/dashboard.html`,
|
||||
features: {
|
||||
invoice_history: { enabled: true },
|
||||
payment_method_update: { enabled: true },
|
||||
customer_update: { enabled: true, allowed_updates: ['address', 'name', 'email'] },
|
||||
subscription_cancel: {
|
||||
enabled: true,
|
||||
mode: 'at_period_end',
|
||||
cancellation_reason: {
|
||||
enabled: true,
|
||||
options: ['too_expensive', 'unused', 'switched_service', 'other'],
|
||||
},
|
||||
},
|
||||
},
|
||||
metadata: { webklar: 'wartungs-abo' },
|
||||
})
|
||||
cachedPortalConfigId = created.id
|
||||
return created.id
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Appwrite-Spiegel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listSubscriptionsForCustomer(customerId) {
|
||||
if (!customerId) return []
|
||||
return listDocuments(config.collections.subscriptions, [
|
||||
Query.equal('customerId', customerId),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(20),
|
||||
])
|
||||
}
|
||||
|
||||
/** Aktuell laufendes Abo (oder das zuletzt beendete, falls keins laeuft). */
|
||||
export async function getCurrentSubscription(customerId) {
|
||||
const subs = await listSubscriptionsForCustomer(customerId)
|
||||
return subs.find((s) => ACTIVE_STATUSES.has(s.status)) || subs[0] || null
|
||||
}
|
||||
|
||||
function toIso(unixSeconds) {
|
||||
return unixSeconds ? new Date(unixSeconds * 1000).toISOString() : null
|
||||
}
|
||||
|
||||
function periodEndOf(sub) {
|
||||
// Seit API 2025-03 liegt current_period_end am Subscription-Item
|
||||
return sub.current_period_end || sub.items?.data?.[0]?.current_period_end || null
|
||||
}
|
||||
|
||||
async function upsertSubscriptionDoc(id, data) {
|
||||
const existing = await getDocument(config.collections.subscriptions, id).catch(() => null)
|
||||
if (existing) return updateDocument(config.collections.subscriptions, id, data)
|
||||
try {
|
||||
return await createDocument(config.collections.subscriptions, data, id)
|
||||
} catch (err) {
|
||||
// Paralleles Event hat das Dokument gerade angelegt
|
||||
if (err.status === 409) return updateDocument(config.collections.subscriptions, id, data)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Stripe-Subscription frisch laden und nach Appwrite spiegeln. */
|
||||
export async function syncSubscription(subscriptionId) {
|
||||
const stripe = getStripe()
|
||||
const sub = await stripe.subscriptions.retrieve(subscriptionId)
|
||||
const meta = sub.metadata || {}
|
||||
const customerId = meta.webklarCustomerId
|
||||
if (!customerId) {
|
||||
console.warn(`[stripe] Subscription ${sub.id} ohne webklarCustomerId - ignoriert`)
|
||||
return null
|
||||
}
|
||||
const customer = typeof sub.customer === 'string' ? null : sub.customer
|
||||
const doc = await upsertSubscriptionDoc(sub.id, {
|
||||
customerId,
|
||||
email: meta.email || customer?.email || '',
|
||||
stripeCustomerId: typeof sub.customer === 'string' ? sub.customer : sub.customer?.id,
|
||||
stripeSubscriptionId: sub.id,
|
||||
status: sub.status,
|
||||
planKey: config.stripe.priceLookupKey,
|
||||
priceAmount: sub.items?.data?.[0]?.price?.unit_amount ?? config.stripe.priceAmountCents,
|
||||
startedAt: toIso(sub.start_date),
|
||||
currentPeriodEnd: toIso(periodEndOf(sub)),
|
||||
cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end || sub.cancel_at),
|
||||
canceledAt: toIso(sub.canceled_at),
|
||||
endedAt: toIso(sub.ended_at),
|
||||
termsVersion: meta.termsVersion || null,
|
||||
termsAcceptedAt: meta.termsAcceptedAt || null,
|
||||
earlyStartConsentAt: meta.earlyStartConsentAt || null,
|
||||
})
|
||||
return doc
|
||||
}
|
||||
|
||||
function addMonths(date, months) {
|
||||
const d = new Date(date)
|
||||
d.setUTCMonth(d.getUTCMonth() + months)
|
||||
return d
|
||||
}
|
||||
|
||||
function subscriptionIdOfInvoice(invoice) {
|
||||
return (
|
||||
invoice.subscription ||
|
||||
invoice.parent?.subscription_details?.subscription ||
|
||||
invoice.lines?.data?.[0]?.parent?.subscription_item_details?.subscription ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
/** Bezahlte Abo-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). */
|
||||
export async function grantCreditForInvoice(invoice) {
|
||||
const ref = subscriptionIdOfInvoice(invoice)
|
||||
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
|
||||
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
|
||||
if (!(invoice.amount_paid > 0)) return { skipped: 'Betrag 0' }
|
||||
|
||||
const sub = await syncSubscription(subscriptionId)
|
||||
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
|
||||
|
||||
const grantedAt = new Date((invoice.status_transitions?.paid_at || invoice.created) * 1000)
|
||||
try {
|
||||
await createDocument(
|
||||
config.collections.hourCredits,
|
||||
{
|
||||
customerId: sub.customerId,
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
stripeInvoiceId: invoice.id,
|
||||
minutes: config.abo.minutesPerMonth,
|
||||
grantedAt: grantedAt.toISOString(),
|
||||
expiresAt: addMonths(grantedAt, config.abo.creditValidityMonths).toISOString(),
|
||||
source: 'stripe_invoice',
|
||||
note: invoice.number ? `Rechnung ${invoice.number}` : '',
|
||||
},
|
||||
invoice.id
|
||||
)
|
||||
return { granted: true, customerId: sub.customerId, subscription: sub }
|
||||
} catch (err) {
|
||||
if (err.status === 409) return { skipped: 'bereits gutgeschrieben' }
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checkout / Kundenportal / Kuendigung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getOrCreateStripeCustomer(customer) {
|
||||
const stripe = getStripe()
|
||||
const subs = await listSubscriptionsForCustomer(customer.$id)
|
||||
const known = subs.find((s) => s.stripeCustomerId)?.stripeCustomerId
|
||||
if (known) return known
|
||||
|
||||
const found = await stripe.customers
|
||||
.search({ query: `metadata['webklarCustomerId']:'${customer.$id}'`, limit: 1 })
|
||||
.catch(() => ({ data: [] }))
|
||||
if (found.data[0]) return found.data[0].id
|
||||
|
||||
const created = await stripe.customers.create({
|
||||
email: customer.email || undefined,
|
||||
name: customer.companyName || customer.name || undefined,
|
||||
preferred_locales: ['de'],
|
||||
address: customer.street
|
||||
? {
|
||||
line1: customer.street,
|
||||
postal_code: customer.postalCode || undefined,
|
||||
city: customer.city || undefined,
|
||||
country: customer.country && customer.country.length === 2 ? customer.country : 'DE',
|
||||
}
|
||||
: undefined,
|
||||
invoice_settings: { footer: config.stripe.invoiceFooter },
|
||||
metadata: { webklarCustomerId: customer.$id, kundennummer: customer.code || '' },
|
||||
})
|
||||
return created.id
|
||||
}
|
||||
|
||||
export async function createAboCheckout(customer, { termsAcceptedAt, earlyStartConsentAt }) {
|
||||
const current = await getCurrentSubscription(customer.$id)
|
||||
if (current && ACTIVE_STATUSES.has(current.status)) {
|
||||
const err = new Error('Sie haben das Wartungs-Abo bereits abgeschlossen.')
|
||||
err.status = 409
|
||||
throw err
|
||||
}
|
||||
const stripe = getStripe()
|
||||
const [priceId, stripeCustomerId] = await Promise.all([
|
||||
ensureAboPrice(),
|
||||
getOrCreateStripeCustomer(customer),
|
||||
])
|
||||
const meta = {
|
||||
webklarCustomerId: customer.$id,
|
||||
email: customer.email || '',
|
||||
termsVersion: config.abo.termsVersion,
|
||||
termsAcceptedAt,
|
||||
earlyStartConsentAt,
|
||||
}
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
customer: stripeCustomerId,
|
||||
client_reference_id: customer.$id,
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
locale: 'de',
|
||||
billing_address_collection: 'required',
|
||||
customer_update: { address: 'auto', name: 'auto' },
|
||||
subscription_data: {
|
||||
metadata: meta,
|
||||
description: 'Webklar Wartungs-Abo',
|
||||
},
|
||||
metadata: meta,
|
||||
custom_text: {
|
||||
submit: {
|
||||
message:
|
||||
'Der Vertrag ist mit Ihrer Bestellung im Kundenportal bereits geschlossen. Hier hinterlegen Sie nur noch die Zahlungsart. 50,00 € monatlich, monatlich kündbar.',
|
||||
},
|
||||
},
|
||||
success_url: `${config.publicBaseUrl}/dashboard.html?abo=success`,
|
||||
cancel_url: `${config.publicBaseUrl}/dashboard.html?abo=cancel`,
|
||||
})
|
||||
return session.url
|
||||
}
|
||||
|
||||
export async function createBillingPortalUrl(customerId) {
|
||||
const sub = await getCurrentSubscription(customerId)
|
||||
if (!sub?.stripeCustomerId) {
|
||||
const err = new Error('Kein Abo vorhanden.')
|
||||
err.status = 404
|
||||
throw err
|
||||
}
|
||||
const configuration = await ensurePortalConfiguration()
|
||||
const session = await getStripe().billingPortal.sessions.create({
|
||||
customer: sub.stripeCustomerId,
|
||||
configuration,
|
||||
locale: 'de',
|
||||
return_url: `${config.publicBaseUrl}/dashboard.html`,
|
||||
})
|
||||
return session.url
|
||||
}
|
||||
|
||||
/** Ordentliche Kuendigung zum Ende des bezahlten Monats. */
|
||||
export async function cancelAtPeriodEnd(subscriptionId) {
|
||||
await getStripe().subscriptions.update(subscriptionId, { cancel_at_period_end: true })
|
||||
return syncSubscription(subscriptionId)
|
||||
}
|
||||
|
||||
export function constructWebhookEvent(rawBody, signature) {
|
||||
if (!config.stripe.webhookSecret) {
|
||||
const err = new Error('STRIPE_WEBHOOK_SECRET fehlt')
|
||||
err.status = 503
|
||||
throw err
|
||||
}
|
||||
return getStripe().webhooks.constructEvent(rawBody, signature, config.stripe.webhookSecret)
|
||||
}
|
||||
Reference in new issue
Block a user