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:
ClaudeandClaude Opus 5.5 committed 2026-09-25 12:04:45 +00:00
1 parent c3f56ba290
commit 65429aa3ac
17 files changed
+1677 -2

No files matched your search

+131
View File
@@ -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 })
}
+39
View File
@@ -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,
})
}
+336
View File
@@ -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)
}