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
@@ -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