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
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { test, after, beforeEach } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import Stripe from 'stripe'
|
||||||
|
import { startFakeStripe } from './support/fakeStripe.js'
|
||||||
|
import { useTestEnv } from './support/testEnv.js'
|
||||||
|
|
||||||
|
// Connect-Dienst des Webshops (Task D1) gegen den Stripe-Nachbau: gesendete Parameter, Kopfzeile Stripe-Account,
|
||||||
|
// Statusabbildung, Webhook-Signatur und Livemode-Regel.
|
||||||
|
const st = await startFakeStripe()
|
||||||
|
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: st.url })
|
||||||
|
const { config } = await import('../server/config.js')
|
||||||
|
const connect = await import('../server/services/shop/connect.js')
|
||||||
|
|
||||||
|
const signer = new Stripe('sk_test_fake')
|
||||||
|
const SECRET = 'whsec_connect_test'
|
||||||
|
const KEY = config.stripe.secretKey
|
||||||
|
const customer = { $id: 'cust1', email: 'inhaber@beispiel.de' }
|
||||||
|
const project = { $id: 'proj1', projectName: 'Bäckerei Muster', liveDomain: 'https://www.baeckerei-muster.de/shop?x=1' }
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
config.stripe.secretKey = KEY
|
||||||
|
config.shop.connectWebhookSecret = ''
|
||||||
|
return st.close()
|
||||||
|
})
|
||||||
|
beforeEach(() => {
|
||||||
|
st.calls.length = 0
|
||||||
|
config.stripe.secretKey = KEY
|
||||||
|
config.shop.connectWebhookSecret = SECRET
|
||||||
|
})
|
||||||
|
|
||||||
|
const signiere = (event, secret = SECRET) => {
|
||||||
|
const payload = JSON.stringify(event)
|
||||||
|
return { payload, signatur: signer.webhooks.generateTestHeaderString({ payload, secret }) }
|
||||||
|
}
|
||||||
|
const rejects = (fn, status) => assert.rejects(Promise.resolve().then(fn), (err) => err.status === status)
|
||||||
|
|
||||||
|
test('kontoAnlegen sendet controller, Capabilities, Profil, Metadaten und Idempotenz-Schluessel', async () => {
|
||||||
|
const id = await connect.kontoAnlegen({ customer, project })
|
||||||
|
assert.match(id, /^acct_/)
|
||||||
|
const [call] = st.calls
|
||||||
|
assert.equal(call.method, 'POST')
|
||||||
|
assert.equal(call.path, '/v1/accounts')
|
||||||
|
assert.equal(call.account, undefined)
|
||||||
|
assert.equal(call.idempotencyKey, 'shop-konto-proj1')
|
||||||
|
assert.deepEqual(call.body, {
|
||||||
|
country: 'DE',
|
||||||
|
email: 'inhaber@beispiel.de',
|
||||||
|
'business_profile[name]': 'Bäckerei Muster',
|
||||||
|
'business_profile[url]': 'https://www.baeckerei-muster.de',
|
||||||
|
'controller[fees][payer]': 'account',
|
||||||
|
'controller[losses][payments]': 'stripe',
|
||||||
|
'controller[stripe_dashboard][type]': 'full',
|
||||||
|
'controller[requirement_collection]': 'stripe',
|
||||||
|
'capabilities[card_payments][requested]': 'true',
|
||||||
|
'capabilities[transfers][requested]': 'true',
|
||||||
|
'metadata[webklar]': 'shop',
|
||||||
|
'metadata[customerId]': 'cust1',
|
||||||
|
'metadata[projectId]': 'proj1',
|
||||||
|
})
|
||||||
|
const konto = st.accounts.get(id)
|
||||||
|
assert.equal(konto.controller.stripe_dashboard.type, 'full')
|
||||||
|
assert.equal(konto.charges_enabled, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kontoAnlegen: gleicher Projekt-Schluessel liefert dasselbe Konto, ohne Live-Domain keine business_profile.url', async () => {
|
||||||
|
const a = await connect.kontoAnlegen({ customer, project })
|
||||||
|
const b = await connect.kontoAnlegen({ customer, project })
|
||||||
|
assert.equal(a, b)
|
||||||
|
st.calls.length = 0
|
||||||
|
await connect.kontoAnlegen({ customer, project: { $id: 'proj2', projectName: 'Ohne Domain' } })
|
||||||
|
assert.equal(st.calls[0].body['business_profile[url]'], undefined)
|
||||||
|
assert.equal(st.calls[0].body['business_profile[name]'], 'Ohne Domain')
|
||||||
|
st.calls.length = 0
|
||||||
|
await connect.kontoAnlegen({ customer, project: { $id: 'proj3', projectName: 'Kaputt', liveDomain: 'nicht gültig' } })
|
||||||
|
assert.equal(st.calls[0].body['business_profile[url]'], undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('onboardingLink sendet Konto, Typ, Rueck-URLs und eventually_due', async () => {
|
||||||
|
const id = await connect.kontoAnlegen({ customer, project })
|
||||||
|
st.calls.length = 0
|
||||||
|
const url = await connect.onboardingLink(id, { returnUrl: 'https://p.test/zurueck', refreshUrl: 'https://p.test/neu' })
|
||||||
|
assert.match(url, /^https:\/\/connect\.stripe\.test\/setup\//)
|
||||||
|
assert.deepEqual(st.calls[0].body, {
|
||||||
|
account: id,
|
||||||
|
type: 'account_onboarding',
|
||||||
|
refresh_url: 'https://p.test/neu',
|
||||||
|
return_url: 'https://p.test/zurueck',
|
||||||
|
'collection_options[fields]': 'eventually_due',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kontoStatus bildet unfertige und fertige Konten ab', async () => {
|
||||||
|
const id = await connect.kontoAnlegen({ customer, project })
|
||||||
|
const neu = await connect.kontoStatus(id)
|
||||||
|
assert.deepEqual({ ...neu, geprueftAt: undefined }, {
|
||||||
|
charges_enabled: false,
|
||||||
|
payouts_enabled: false,
|
||||||
|
details_submitted: false,
|
||||||
|
currently_due: ['external_account'],
|
||||||
|
past_due: [],
|
||||||
|
disabled_reason: null,
|
||||||
|
geprueftAt: undefined,
|
||||||
|
})
|
||||||
|
assert.ok(!Number.isNaN(Date.parse(neu.geprueftAt)))
|
||||||
|
st.setAccount(id, { charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [], past_due: [] } })
|
||||||
|
const fertig = await connect.kontoStatus(id)
|
||||||
|
assert.equal(fertig.charges_enabled, true)
|
||||||
|
assert.equal(fertig.payouts_enabled, true)
|
||||||
|
assert.equal(fertig.details_submitted, true)
|
||||||
|
assert.deepEqual(fertig.currently_due, [])
|
||||||
|
st.setAccount(id, { charges_enabled: false, requirements: { past_due: ['individual.id_number'], disabled_reason: 'requirements.past_due' } })
|
||||||
|
const gesperrt = await connect.kontoStatus(id)
|
||||||
|
assert.deepEqual(gesperrt.past_due, ['individual.id_number'])
|
||||||
|
assert.equal(gesperrt.disabled_reason, 'requirements.past_due')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('statusAusKonto ist rein und robust gegen fehlende Felder', () => {
|
||||||
|
const now = new Date('2026-10-05T10:00:00Z')
|
||||||
|
assert.deepEqual(connect.statusAusKonto({}, now), {
|
||||||
|
charges_enabled: false, payouts_enabled: false, details_submitted: false,
|
||||||
|
currently_due: [], past_due: [], disabled_reason: null, geprueftAt: '2026-10-05T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
const eingabe = { charges_enabled: true, requirements: { currently_due: ['a'] } }
|
||||||
|
const aus = connect.statusAusKonto(eingabe, now)
|
||||||
|
aus.currently_due.push('b')
|
||||||
|
assert.deepEqual(eingabe.requirements.currently_due, ['a'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Checkout, Abruf, Ablauf: Stripe-Account-Kopfzeile, Idempotenz, Ablage je Konto', async () => {
|
||||||
|
const id = await connect.kontoAnlegen({ customer, project })
|
||||||
|
const other = 'acct_andere1'
|
||||||
|
st.calls.length = 0
|
||||||
|
const params = { mode: 'payment', success_url: 'https://x.test/ok', cancel_url: 'https://x.test/nein', metadata: { wkShop: '1', orderId: 'o1' }, expires_at: 1900000000 }
|
||||||
|
const sess = await connect.checkoutAnlegen(id, params, { idempotencyKey: 'shop-checkout-o1' })
|
||||||
|
assert.match(sess.id, /^cs_/)
|
||||||
|
assert.match(sess.url, /^https:\/\/checkout\.stripe\.test\//)
|
||||||
|
assert.equal(st.calls[0].account, id)
|
||||||
|
assert.equal(st.calls[0].idempotencyKey, 'shop-checkout-o1')
|
||||||
|
assert.equal(st.calls[0].body['metadata[wkShop]'], '1')
|
||||||
|
const again = await connect.checkoutAnlegen(id, params, { idempotencyKey: 'shop-checkout-o1' })
|
||||||
|
assert.equal(again.id, sess.id)
|
||||||
|
assert.equal(st.accountSessions(id).size, 1)
|
||||||
|
assert.equal(st.checkoutSessions.size, 0, 'Plattform-Ablage bleibt unberuehrt')
|
||||||
|
|
||||||
|
const geholt = await connect.sessionHolen(id, sess.id, { expand: ['payment_intent'] })
|
||||||
|
assert.equal(geholt.status, 'open')
|
||||||
|
const get = st.calls.at(-1)
|
||||||
|
assert.equal(get.account, id)
|
||||||
|
assert.equal(get.query['expand[0]'], 'payment_intent')
|
||||||
|
await assert.rejects(connect.sessionHolen(other, sess.id), (err) => err.statusCode === 404)
|
||||||
|
|
||||||
|
const expired = await connect.sessionAblaufen(id, sess.id)
|
||||||
|
assert.equal(expired.status, 'expired')
|
||||||
|
assert.equal(st.calls.at(-1).account, id)
|
||||||
|
assert.equal(st.calls.at(-1).path, `/v1/checkout/sessions/${sess.id}/expire`)
|
||||||
|
await assert.rejects(connect.sessionAblaufen(id, sess.id), (err) => err.statusCode === 400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('erstatten: Teil- und Restbetrag, Konto-Kopfzeile, Idempotenz, Obergrenze', async () => {
|
||||||
|
const id = await connect.kontoAnlegen({ customer, project })
|
||||||
|
st.setPaymentIntent(id, 'pi_abc', { amount: 2500 })
|
||||||
|
st.calls.length = 0
|
||||||
|
const teil = await connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 1000, grund: 'Artikel beschädigt', idempotencyKey: 'shop-erstattung-o1-1' })
|
||||||
|
assert.equal(teil.amount, 1000)
|
||||||
|
assert.deepEqual(st.calls[0].body, {
|
||||||
|
payment_intent: 'pi_abc',
|
||||||
|
amount: '1000',
|
||||||
|
reason: 'requested_by_customer',
|
||||||
|
'metadata[webklar]': 'shop',
|
||||||
|
'metadata[grund]': 'Artikel beschädigt',
|
||||||
|
})
|
||||||
|
assert.equal(st.calls[0].account, id)
|
||||||
|
assert.equal(st.calls[0].idempotencyKey, 'shop-erstattung-o1-1')
|
||||||
|
const wieder = await connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 1000, idempotencyKey: 'shop-erstattung-o1-1' })
|
||||||
|
assert.equal(wieder.id, teil.id)
|
||||||
|
assert.equal(st.refunds(id).size, 1)
|
||||||
|
const rest = await connect.erstatten(id, { paymentIntent: 'pi_abc' })
|
||||||
|
assert.equal(rest.amount, 1500, 'ohne Betrag der Rest')
|
||||||
|
await assert.rejects(connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 1 }), (err) => err.statusCode === 400)
|
||||||
|
await rejects(() => connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 10.5 }), 400)
|
||||||
|
await rejects(() => connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 0 }), 400)
|
||||||
|
await rejects(() => connect.erstatten(id, { paymentIntent: '../x', betragCent: 100 }), 400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Konto-ID wird geprueft, bevor etwas gesendet wird', async () => {
|
||||||
|
st.calls.length = 0
|
||||||
|
for (const bad of ['', 'acct_', 'acct_a/b', 'cus_123', 'acct_ab cd', null, undefined, 'acct_x?y=1']) {
|
||||||
|
await rejects(() => connect.kontoStatus(bad), 400)
|
||||||
|
await rejects(() => connect.onboardingLink(bad, { returnUrl: 'https://a.test', refreshUrl: 'https://a.test' }), 400)
|
||||||
|
await rejects(() => connect.checkoutAnlegen(bad, {}), 400)
|
||||||
|
await rejects(() => connect.sessionHolen(bad, 'cs_test_1'), 400)
|
||||||
|
await rejects(() => connect.sessionAblaufen(bad, 'cs_test_1'), 400)
|
||||||
|
await rejects(() => connect.erstatten(bad, { paymentIntent: 'pi_1' }), 400)
|
||||||
|
}
|
||||||
|
await rejects(() => connect.sessionHolen('acct_ok1', '../x'), 400)
|
||||||
|
assert.equal(st.calls.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Stripe-Fehler (Konto unbekannt) kommen als Fehler an', async () => {
|
||||||
|
await assert.rejects(connect.kontoStatus('acct_gibtesnicht'), (err) => err.statusCode === 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('connectEreignis: gueltige Signatur liefert das Ereignis', () => {
|
||||||
|
const event = { id: 'evt_1', object: 'event', type: 'account.updated', account: 'acct_x1', livemode: false, data: { object: { id: 'acct_x1' } } }
|
||||||
|
const { payload, signatur } = signiere(event)
|
||||||
|
const aus = connect.connectEreignis(Buffer.from(payload), signatur)
|
||||||
|
assert.equal(aus.id, 'evt_1')
|
||||||
|
assert.equal(aus.account, 'acct_x1')
|
||||||
|
assert.equal(connect.connectEreignis(payload, signatur).id, 'evt_1', 'auch als Text')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('connectEreignis: ungueltige oder fehlende Signatur ist 400', () => {
|
||||||
|
const event = { id: 'evt_1', object: 'event', type: 'account.updated', livemode: false, data: { object: {} } }
|
||||||
|
const { payload } = signiere(event)
|
||||||
|
assert.throws(() => connect.connectEreignis(payload, signiere(event, 'whsec_falsch').signatur), (err) => err.status === 400)
|
||||||
|
assert.throws(() => connect.connectEreignis(payload, undefined), (err) => err.status === 400)
|
||||||
|
assert.throws(() => connect.connectEreignis(payload, 'quatsch'), (err) => err.status === 400)
|
||||||
|
const { signatur } = signiere(event)
|
||||||
|
assert.throws(() => connect.connectEreignis(payload.replace('evt_1', 'evt_2'), signatur), (err) => err.status === 400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('connectEreignis: ohne Secret 503', () => {
|
||||||
|
config.shop.connectWebhookSecret = ''
|
||||||
|
const event = { id: 'evt_1', object: 'event', type: 'x', livemode: false, data: { object: {} } }
|
||||||
|
const { payload, signatur } = signiere(event)
|
||||||
|
assert.throws(() => connect.connectEreignis(payload, signatur), (err) => err.status === 503)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('connectEreignis: mit Live-Schluessel zaehlen nur Live-Ereignisse', () => {
|
||||||
|
config.stripe.secretKey = 'sk_live_fake'
|
||||||
|
const test = { id: 'evt_t', object: 'event', type: 'account.updated', livemode: false, data: { object: {} } }
|
||||||
|
const live = { id: 'evt_l', object: 'event', type: 'account.updated', livemode: true, data: { object: {} } }
|
||||||
|
const a = signiere(test)
|
||||||
|
assert.equal(connect.connectEreignis(a.payload, a.signatur), null)
|
||||||
|
const b = signiere(live)
|
||||||
|
assert.equal(connect.connectEreignis(b.payload, b.signatur).id, 'evt_l')
|
||||||
|
// Signatur wird trotzdem zuerst geprueft
|
||||||
|
assert.throws(() => connect.connectEreignis(a.payload, b.signatur), (err) => err.status === 400)
|
||||||
|
// Testschluessel: beide Arten gehen durch
|
||||||
|
config.stripe.secretKey = KEY
|
||||||
|
assert.equal(connect.connectEreignis(a.payload, a.signatur).id, 'evt_t')
|
||||||
|
})
|
||||||
+155
-3
@@ -32,6 +32,16 @@ import http from 'node:http'
|
|||||||
* Kunden: GET /v1/customers/search (Metadaten), POST /v1/customers (anlegen) und POST /v1/customers/:id (aendern:
|
* Kunden: GET /v1/customers/search (Metadaten), POST /v1/customers (anlegen) und POST /v1/customers/:id (aendern:
|
||||||
* email, name, address, metadata; unbekannte ID -> 404). Wie Stripe fuehrt ein Update die Anschrift feldweise zusammen
|
* email, name, address, metadata; unbekannte ID -> 404). Wie Stripe fuehrt ein Update die Anschrift feldweise zusammen
|
||||||
* (nur gesendete Felder aendern sich) und ein leerer Text loescht das Feld (gespeichert als null).
|
* (nur gesendete Felder aendern sich) und ein leerer Text loescht das Feld (gespeichert als null).
|
||||||
|
*
|
||||||
|
* Connect (Webshop D1): die Kopfzeile Stripe-Account steht als account in calls (nur bei Aufrufen mit Konto). POST /v1/accounts
|
||||||
|
* (v1 mit controller; Konto startet unfertig: charges_enabled false, requirements.currently_due ['external_account'];
|
||||||
|
* gleicher Idempotency-Key liefert dasselbe Konto), GET /v1/accounts/:id (unbekannt -> 404), POST /v1/account_links
|
||||||
|
* (Konto muss existieren). st.setAccount(id, patch) legt ein Konto an oder aendert es (requirements wird feldweise gemischt).
|
||||||
|
* Aufrufe mit Stripe-Account arbeiten auf der Ablage dieses Kontos (st.accountSessions(id), st.refunds(id)); ohne Kopfzeile
|
||||||
|
* bleibt alles wie bisher in st.checkoutSessions. Dort: POST /v1/checkout/sessions, GET .../:id, POST .../:id/expire (nur open),
|
||||||
|
* POST /v1/refunds (amount fehlt = Rest der Zahlung; Zahlungen legt st.setPaymentIntent(acct, id, { amount }) an; Summe der
|
||||||
|
* Erstattungen darf die Zahlung nicht uebersteigen) und GET /v1/refunds/:id. Idempotency-Key wiederholt die erste Antwort
|
||||||
|
* (je Konto und Schluessel) fuer Sitzungen und Erstattungen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/
|
// Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/
|
||||||
@@ -54,6 +64,16 @@ export async function startFakeStripe() {
|
|||||||
// Rechnungsposten ohne Rechnung (pending invoice items), je ID
|
// Rechnungsposten ohne Rechnung (pending invoice items), je ID
|
||||||
const invoiceItems = new Map()
|
const invoiceItems = new Map()
|
||||||
const checkoutSessions = new Map()
|
const checkoutSessions = new Map()
|
||||||
|
// Connect: Konten, deren Sitzungen, Zahlungen und Erstattungen (je Konto-ID), Antworten je Idempotency-Key
|
||||||
|
const accounts = new Map()
|
||||||
|
const accountSessionMaps = new Map()
|
||||||
|
const paymentIntentMaps = new Map()
|
||||||
|
const refundMaps = new Map()
|
||||||
|
const idempotentReplies = new Map()
|
||||||
|
const bucket = (maps, accountId) => {
|
||||||
|
if (!maps.has(accountId)) maps.set(accountId, new Map())
|
||||||
|
return maps.get(accountId)
|
||||||
|
}
|
||||||
// Abo-Aenderungen, die Stripe bis zur Zahlung zurueckhaelt (pending_update), je Abo-ID
|
// Abo-Aenderungen, die Stripe bis zur Zahlung zurueckhaelt (pending_update), je Abo-ID
|
||||||
const pendingUpdates = new Map()
|
const pendingUpdates = new Map()
|
||||||
const calls = []
|
const calls = []
|
||||||
@@ -166,8 +186,112 @@ export async function startFakeStripe() {
|
|||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = (method, path, query, body) => {
|
const route = (method, path, query, body, account = null) => {
|
||||||
let m
|
let m
|
||||||
|
// ---- Connect ----
|
||||||
|
if (method === 'POST' && path === '/v1/accounts') {
|
||||||
|
const data = unflatten(body)
|
||||||
|
const id = `acct_test${(seq += 1)}`
|
||||||
|
const obj = {
|
||||||
|
id,
|
||||||
|
object: 'account',
|
||||||
|
type: 'standard',
|
||||||
|
country: data.country || 'DE',
|
||||||
|
email: data.email ?? null,
|
||||||
|
business_profile: data.business_profile || {},
|
||||||
|
controller: { type: 'application', is_controller: false, ...(data.controller || {}) },
|
||||||
|
capabilities: Object.fromEntries(Object.keys(data.capabilities || {}).map((k) => [k, 'inactive'])),
|
||||||
|
metadata: data.metadata || {},
|
||||||
|
charges_enabled: false,
|
||||||
|
payouts_enabled: false,
|
||||||
|
details_submitted: false,
|
||||||
|
requirements: { currently_due: ['external_account'], eventually_due: ['external_account'], past_due: [], errors: [], disabled_reason: null },
|
||||||
|
}
|
||||||
|
accounts.set(id, obj)
|
||||||
|
return [200, obj]
|
||||||
|
}
|
||||||
|
if (method === 'GET' && (m = path.match(/^\/v1\/accounts\/([^/]+)$/))) {
|
||||||
|
return accounts.has(m[1]) ? [200, accounts.get(m[1])] : noSuch('account', m[1])
|
||||||
|
}
|
||||||
|
if (method === 'POST' && path === '/v1/account_links') {
|
||||||
|
const data = unflatten(body)
|
||||||
|
if (!accounts.has(data.account)) return noSuch('account', data.account)
|
||||||
|
if (!data.type) return invalid('type', 'Missing required param: type.')
|
||||||
|
return [200, {
|
||||||
|
object: 'account_link',
|
||||||
|
created: nowUnix(),
|
||||||
|
expires_at: nowUnix() + 300,
|
||||||
|
url: `https://connect.stripe.test/setup/${data.account}/${(seq += 1)}`,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
if (account) {
|
||||||
|
const sessions = bucket(accountSessionMaps, account)
|
||||||
|
if (method === 'POST' && path === '/v1/checkout/sessions') {
|
||||||
|
const data = unflatten(body)
|
||||||
|
const id = nextId('cs')
|
||||||
|
const obj = {
|
||||||
|
id,
|
||||||
|
object: 'checkout.session',
|
||||||
|
url: `https://checkout.stripe.test/c/pay/${id}`,
|
||||||
|
mode: data.mode || 'payment',
|
||||||
|
client_reference_id: data.client_reference_id ?? null,
|
||||||
|
success_url: data.success_url ?? null,
|
||||||
|
cancel_url: data.cancel_url ?? null,
|
||||||
|
expires_at: data.expires_at ? Number(data.expires_at) : nowUnix() + 86400,
|
||||||
|
metadata: data.metadata || {},
|
||||||
|
status: 'open',
|
||||||
|
payment_status: 'unpaid',
|
||||||
|
payment_intent: null,
|
||||||
|
amount_total: null,
|
||||||
|
currency: 'eur',
|
||||||
|
customer_details: null,
|
||||||
|
}
|
||||||
|
sessions.set(id, obj)
|
||||||
|
return [200, obj]
|
||||||
|
}
|
||||||
|
if (method === 'GET' && (m = path.match(/^\/v1\/checkout\/sessions\/([^/]+)$/))) {
|
||||||
|
return sessions.has(m[1]) ? [200, sessions.get(m[1])] : noSuch('checkout.session', m[1])
|
||||||
|
}
|
||||||
|
if (method === 'POST' && (m = path.match(/^\/v1\/checkout\/sessions\/([^/]+)\/expire$/))) {
|
||||||
|
const found = sessions.get(m[1])
|
||||||
|
if (!found) return noSuch('checkout.session', m[1])
|
||||||
|
if (found.status !== 'open') return invalid(undefined, 'Only Checkout Sessions with a status in open can be expired.')
|
||||||
|
const expired = { ...found, status: 'expired' }
|
||||||
|
sessions.set(found.id, expired)
|
||||||
|
return [200, expired]
|
||||||
|
}
|
||||||
|
if (method === 'POST' && path === '/v1/refunds') {
|
||||||
|
const data = unflatten(body)
|
||||||
|
const intents = bucket(paymentIntentMaps, account)
|
||||||
|
const refunds = bucket(refundMaps, account)
|
||||||
|
const intent = intents.get(data.payment_intent)
|
||||||
|
if (!data.payment_intent) return invalid('payment_intent', 'Either payment_intent or charge must be provided.')
|
||||||
|
if (!intent && !data.amount) return noSuch('payment_intent', data.payment_intent)
|
||||||
|
const already = [...refunds.values()].filter((r) => r.payment_intent === data.payment_intent).reduce((sum, r) => sum + r.amount, 0)
|
||||||
|
const rest = intent ? intent.amount - already : null
|
||||||
|
const amount = data.amount ? Number(data.amount) : rest
|
||||||
|
if (!Number.isInteger(amount) || amount <= 0) return invalid('amount', 'Invalid positive integer')
|
||||||
|
if (rest !== null && amount > rest) return invalid('amount', `Refund amount (${amount}) is greater than unrefunded amount on payment (${rest}).`)
|
||||||
|
const obj = {
|
||||||
|
id: nextId('re'),
|
||||||
|
object: 'refund',
|
||||||
|
amount,
|
||||||
|
currency: 'eur',
|
||||||
|
payment_intent: data.payment_intent,
|
||||||
|
reason: data.reason ?? null,
|
||||||
|
metadata: data.metadata || {},
|
||||||
|
status: 'succeeded',
|
||||||
|
created: nowUnix(),
|
||||||
|
}
|
||||||
|
refunds.set(obj.id, obj)
|
||||||
|
return [200, obj]
|
||||||
|
}
|
||||||
|
if (method === 'GET' && (m = path.match(/^\/v1\/refunds\/([^/]+)$/))) {
|
||||||
|
const found = bucket(refundMaps, account).get(m[1])
|
||||||
|
return found ? [200, found] : noSuch('refund', m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---- Plattform ----
|
||||||
if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) {
|
if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) {
|
||||||
return subscriptions.has(m[1]) ? [200, subscriptions.get(m[1])] : noSuch('subscription', m[1])
|
return subscriptions.has(m[1]) ? [200, subscriptions.get(m[1])] : noSuch('subscription', m[1])
|
||||||
}
|
}
|
||||||
@@ -626,7 +750,9 @@ export async function startFakeStripe() {
|
|||||||
const body = await readBody(req)
|
const body = await readBody(req)
|
||||||
// idempotencyKey (Kopfzeile Idempotency-Key) steht nur an Aufrufen, die einen mitschicken
|
// idempotencyKey (Kopfzeile Idempotency-Key) steht nur an Aufrufen, die einen mitschicken
|
||||||
const idempotencyKey = req.headers['idempotency-key']
|
const idempotencyKey = req.headers['idempotency-key']
|
||||||
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body), ...(idempotencyKey ? { idempotencyKey } : {}) })
|
// Connect: Kopfzeile Stripe-Account (Aufruf im Namen eines Haendlerkontos)
|
||||||
|
const account = req.headers['stripe-account']
|
||||||
|
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body), ...(idempotencyKey ? { idempotencyKey } : {}), ...(account ? { account } : {}) })
|
||||||
const delay = plannedDelay(req.method, url.pathname)
|
const delay = plannedDelay(req.method, url.pathname)
|
||||||
if (delay) await new Promise((resolve) => setTimeout(resolve, delay))
|
if (delay) await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
const failure = plannedFailure(req.method, url.pathname)
|
const failure = plannedFailure(req.method, url.pathname)
|
||||||
@@ -640,7 +766,10 @@ export async function startFakeStripe() {
|
|||||||
res.writeHead(200, { 'Content-Type': 'application/pdf' })
|
res.writeHead(200, { 'Content-Type': 'application/pdf' })
|
||||||
return res.end(`%PDF-1.4\n% Stripe-Nachbau ${pdfMatch[1]}\n%%EOF\n`)
|
return res.end(`%PDF-1.4\n% Stripe-Nachbau ${pdfMatch[1]}\n%%EOF\n`)
|
||||||
}
|
}
|
||||||
const [status, payload] = failure || route(req.method, url.pathname, url.searchParams, body)
|
// Wiederholung mit gleichem Idempotency-Key (nur Connect-Aufrufe und Kontoanlage): erste Antwort, kein neuer Zustand
|
||||||
|
const replyKey = !failure && req.method === 'POST' && idempotencyKey && (account || url.pathname === '/v1/accounts') ? `${account || ''}|${idempotencyKey}` : null
|
||||||
|
const [status, payload] = failure || (replyKey && idempotentReplies.get(replyKey)) || route(req.method, url.pathname, url.searchParams, body, account || null)
|
||||||
|
if (replyKey && status === 200 && !idempotentReplies.has(replyKey)) idempotentReplies.set(replyKey, [status, payload])
|
||||||
res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` })
|
res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` })
|
||||||
res.end(JSON.stringify(payload))
|
res.end(JSON.stringify(payload))
|
||||||
})
|
})
|
||||||
@@ -659,6 +788,29 @@ export async function startFakeStripe() {
|
|||||||
payouts,
|
payouts,
|
||||||
invoiceItems,
|
invoiceItems,
|
||||||
checkoutSessions,
|
checkoutSessions,
|
||||||
|
accounts,
|
||||||
|
/** Sitzungen eines Haendlerkontos (Map id -> Sitzung); Tests setzen status/payment_status/payment_intent direkt darin. */
|
||||||
|
accountSessions: (accountId) => bucket(accountSessionMaps, accountId),
|
||||||
|
/** Erstattungen eines Haendlerkontos (Map id -> Erstattung). */
|
||||||
|
refunds: (accountId) => bucket(refundMaps, accountId),
|
||||||
|
/** Konto anlegen oder aendern (z. B. { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } }). */
|
||||||
|
setAccount(id, patch = {}) {
|
||||||
|
const base = accounts.get(id) || {
|
||||||
|
id, object: 'account', type: 'standard', country: 'DE', email: null, business_profile: {}, metadata: {},
|
||||||
|
controller: { type: 'application', is_controller: false }, capabilities: {},
|
||||||
|
charges_enabled: false, payouts_enabled: false, details_submitted: false,
|
||||||
|
requirements: { currently_due: ['external_account'], eventually_due: [], past_due: [], errors: [], disabled_reason: null },
|
||||||
|
}
|
||||||
|
const next = { ...base, ...patch, requirements: { ...base.requirements, ...(patch.requirements || {}) } }
|
||||||
|
accounts.set(id, next)
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
/** Zahlung eines Haendlerkontos (Betrag in Cent) fuer Erstattungen: setPaymentIntent('acct_x', 'pi_x', { amount: 2500 }). */
|
||||||
|
setPaymentIntent(accountId, id, { amount, currency = 'eur' }) {
|
||||||
|
const obj = { id, object: 'payment_intent', amount, currency }
|
||||||
|
bucket(paymentIntentMaps, accountId).set(id, obj)
|
||||||
|
return obj
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. latest_invoice). Mit
|
* Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. latest_invoice). Mit
|
||||||
* pending_update bleibt das Abo wie bei Stripe unveraendert (und traegt pending_update), bis
|
* pending_update bleibt das Abo wie bei Stripe unveraendert (und traegt pending_update), bis
|
||||||
|
|||||||
Reference in new issue
Block a user