Shop D1: Connect-Dienst (Konto, Onboarding, Checkout, Erstattung, Webhook-Prüfung) und Fake-Stripe für Connect
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
03a7f1d877
commit
da43ee2b41
3 files changed
+556
-3
No files matched your search
@@ -0,0 +1,159 @@
|
||||
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
|
||||
}
|
||||
|
||||
/** Bereitschaftsstatus aus einem Konto-Objekt (auch fuer den Webhook account.updated). Rein bis auf die Zeit. */
|
||||
export function statusAusKonto(account, now = new Date()) {
|
||||
const req = account?.requirements || {}
|
||||
return {
|
||||
charges_enabled: account?.charges_enabled === true,
|
||||
payouts_enabled: account?.payouts_enabled === true,
|
||||
details_submitted: account?.details_submitted === true,
|
||||
currently_due: Array.isArray(req.currently_due) ? [...req.currently_due] : [],
|
||||
past_due: Array.isArray(req.past_due) ? [...req.past_due] : [],
|
||||
disabled_reason: req.disabled_reason ?? null,
|
||||
geprueftAt: new Date(now).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user