import { config } from '../../config.js' import { getStripe } from '../stripeBilling.js' /** * Stripe Connect fuer den Webshop (Plan 2026-10-05-webshop.md, Task D1). Der Haendler verkauft ueber ein eigenes * Stripe-Konto (Accounts v1 mit controller-Eigenschaften = Standard-aehnlich: eigenes Voll-Dashboard, Stripe-Gebuehren * beim Haendler, Stripe haftet fuer Negativsalden). Zahlungen sind Direct Charges: alle Aufrufe auf Ressourcen des Kontos * (Checkout, Erstattung) tragen { stripeAccount: accountId }. Konto selbst und Kontolinks sind Plattform-Aufrufe. * Fehler: Error mit status (400 = ungueltige Eingabe/Signatur, 503 = nicht eingerichtet), Texte deutsch und ohne Kundendaten. */ const ACCOUNT_RE = /^acct_[A-Za-z0-9]+$/ const SESSION_RE = /^cs_[A-Za-z0-9_]+$/ const PAYMENT_INTENT_RE = /^pi_[A-Za-z0-9_]+$/ const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/ function fehler(status, text) { const err = new Error(text) err.status = status return err } function pruefeKonto(accountId) { if (typeof accountId !== 'string' || !ACCOUNT_RE.test(accountId)) throw fehler(400, 'Ungültige Stripe-Konto-ID.') return accountId } function pruefeSession(id) { if (typeof id !== 'string' || !SESSION_RE.test(id)) throw fehler(400, 'Ungültige Checkout-Sitzung.') return id } /** https-Adresse der Live-Domain des Projekts (ohne Pfad), sonst undefined. */ function liveUrl(project) { const host = String(project?.liveDomain || '') .trim() .toLowerCase() .replace(/^[a-z]+:\/\//, '') .replace(/[/?#].*$/, '') return HOST_RE.test(host) ? `https://${host}` : undefined } /** Legt das Haendlerkonto an und liefert die Konto-ID. Idempotent je Projekt (Stripe liefert bei gleichem Schluessel dasselbe Konto). */ export async function kontoAnlegen({ customer, project }) { const customerId = String(customer?.$id || customer?.id || '') const projectId = String(project?.$id || project?.id || '') if (!projectId) throw fehler(400, 'Projekt fehlt.') const url = liveUrl(project) const name = String(project?.projectName || project?.subdomain || '').trim().slice(0, 100) const businessProfile = { ...(name ? { name } : {}), ...(url ? { url } : {}) } const account = await getStripe().accounts.create( { country: 'DE', ...(customer?.email ? { email: String(customer.email) } : {}), ...(Object.keys(businessProfile).length ? { business_profile: businessProfile } : {}), controller: { fees: { payer: 'account' }, losses: { payments: 'stripe' }, stripe_dashboard: { type: 'full' }, requirement_collection: 'stripe', }, capabilities: { card_payments: { requested: true }, transfers: { requested: true } }, metadata: { webklar: 'shop', customerId, projectId }, }, { idempotencyKey: `shop-konto-${projectId}` } ) return account.id } /** Einmaliger Onboarding-Link (laeuft nach wenigen Minuten ab, nie per Mail verschicken). */ export async function onboardingLink(accountId, { returnUrl, refreshUrl }) { pruefeKonto(accountId) const link = await getStripe().accountLinks.create({ account: accountId, type: 'account_onboarding', refresh_url: refreshUrl, return_url: returnUrl, collection_options: { fields: 'eventually_due' }, }) return link.url } const MAX_EINTRAEGE = 30 const MAX_EINTRAG_ZEICHEN = 100 /** Die Spalte stripeStatus fasst 4000 Zeichen; darunter bleibt Platz fuer den Hinweis zu Klarna/SEPA (merkeHinweis). */ const MAX_STATUS_JSON = 3500 const kapp = (liste) => (Array.isArray(liste) ? liste : []).filter((e) => typeof e === 'string').slice(0, MAX_EINTRAEGE).map((e) => e.slice(0, MAX_EINTRAG_ZEICHEN)) /** * Bereitschaftsstatus aus einem Konto-Objekt (auch fuer den Webhook account.updated). Rein bis auf die Zeit. Die Listen werden * auf 30 Eintraege zu je 100 Zeichen gekappt und notfalls weiter gekuerzt, damit das JSON in die Spalte stripeStatus (4000 Zeichen) passt. */ export function statusAusKonto(account, now = new Date()) { const req = account?.requirements || {} const status = { charges_enabled: account?.charges_enabled === true, payouts_enabled: account?.payouts_enabled === true, details_submitted: account?.details_submitted === true, currently_due: kapp(req.currently_due), past_due: kapp(req.past_due), disabled_reason: typeof req.disabled_reason === 'string' ? req.disabled_reason.slice(0, MAX_EINTRAG_ZEICHEN) : (req.disabled_reason ?? null), geprueftAt: new Date(now).toISOString(), } while (JSON.stringify(status).length > MAX_STATUS_JSON) { const laenger = status.currently_due.length >= status.past_due.length ? status.currently_due : status.past_due if (laenger.length === 0) break laenger.pop() } return status } export async function kontoStatus(accountId) { pruefeKonto(accountId) return statusAusKonto(await getStripe().accounts.retrieve(accountId)) } /** Checkout-Sitzung auf dem Haendlerkonto (Direct Charge). */ export async function checkoutAnlegen(accountId, params, { idempotencyKey } = {}) { pruefeKonto(accountId) const session = await getStripe().checkout.sessions.create(params, { stripeAccount: accountId, ...(idempotencyKey ? { idempotencyKey } : {}), }) return { id: session.id, url: session.url } } export async function sessionHolen(accountId, id, { expand } = {}) { pruefeKonto(accountId) pruefeSession(id) return getStripe().checkout.sessions.retrieve(id, expand?.length ? { expand } : {}, { stripeAccount: accountId }) } /** Offene Sitzung beenden (Stripe lehnt ab, wenn sie nicht mehr offen ist). */ export async function sessionAblaufen(accountId, id) { pruefeKonto(accountId) pruefeSession(id) return getStripe().checkout.sessions.expire(id, {}, { stripeAccount: accountId }) } /** Erstattung auf dem Haendlerkonto; ohne betragCent wird voll erstattet. */ export async function erstatten(accountId, { paymentIntent, betragCent, grund, idempotencyKey } = {}) { pruefeKonto(accountId) if (typeof paymentIntent !== 'string' || !PAYMENT_INTENT_RE.test(paymentIntent)) throw fehler(400, 'Ungültige Zahlung.') if (betragCent !== undefined && (!Number.isInteger(betragCent) || betragCent <= 0)) throw fehler(400, 'Ungültiger Erstattungsbetrag.') const text = typeof grund === 'string' ? grund.trim().slice(0, 500) : '' return getStripe().refunds.create( { payment_intent: paymentIntent, ...(betragCent !== undefined ? { amount: betragCent } : {}), reason: 'requested_by_customer', metadata: { webklar: 'shop', ...(text ? { grund: text } : {}) }, }, { stripeAccount: accountId, ...(idempotencyKey ? { idempotencyKey } : {}) } ) } /** * Prueft Signatur des Connect-Endpoints und liefert das Ereignis. 503 ohne Secret, 400 bei ungueltiger Signatur. * Mit Live-Schluessel zaehlen nur Live-Ereignisse (Connect-Endpoints bekommen auch Test-Ereignisse): sonst null, der * Aufrufer ignoriert sie (200 ohne Wirkung). */ export function connectEreignis(rawBody, signatur) { const secret = config.shop.connectWebhookSecret if (!secret) throw fehler(503, 'Der Connect-Webhook ist nicht eingerichtet.') const stripe = getStripe() let event try { event = stripe.webhooks.constructEvent(rawBody, signatur, secret) } catch { throw fehler(400, 'Ungültige Webhook-Signatur.') } if (String(config.stripe.secretKey).startsWith('sk_live') && event.livemode !== true) return null return event } /** * Neuer Statuswert fuer das Speichern: ein gemerkter Hinweis (Klarna/SEPA beim Haendler nicht freigeschaltet) bleibt, solange das * Konto Zahlungen annimmt und der Shop Klarna/SEPA anbietet; sonst gilt der frisch gelesene Status. Gleiche Regel wie * POST /api/shop/stripe/aktualisieren. */ export function statusMitHinweis(alt, status, zahlungsarten) { const arten = Array.isArray(zahlungsarten) ? zahlungsarten : ['card'] const behalte = typeof alt?.hinweis === 'string' && alt.hinweis !== '' && status.charges_enabled === true && arten.some((z) => z === 'klarna' || z === 'sepa') return behalte ? { ...status, hinweis: alt.hinweis } : status } /** Alle Erstattungen einer Zahlung des Haendlerkontos (bis 100; Stripe liefert sie im charge.refunded-Ereignis nicht immer mit). */ export async function erstattungenZurZahlung(accountId, paymentIntent) { pruefeKonto(accountId) if (typeof paymentIntent !== 'string' || !PAYMENT_INTENT_RE.test(paymentIntent)) throw fehler(400, 'Ungültige Zahlung.') const liste = await getStripe().refunds.list({ payment_intent: paymentIntent, limit: 100 }, { stripeAccount: accountId }) return Array.isArray(liste?.data) ? liste.data : [] }