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:
|
||||
* 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).
|
||||
*
|
||||
* 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/
|
||||
@@ -54,6 +64,16 @@ export async function startFakeStripe() {
|
||||
// Rechnungsposten ohne Rechnung (pending invoice items), je ID
|
||||
const invoiceItems = 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
|
||||
const pendingUpdates = new Map()
|
||||
const calls = []
|
||||
@@ -166,8 +186,112 @@ export async function startFakeStripe() {
|
||||
return merged
|
||||
}
|
||||
|
||||
const route = (method, path, query, body) => {
|
||||
const route = (method, path, query, body, account = null) => {
|
||||
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\/([^/]+)$/))) {
|
||||
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)
|
||||
// idempotencyKey (Kopfzeile Idempotency-Key) steht nur an Aufrufen, die einen mitschicken
|
||||
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)
|
||||
if (delay) await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
const failure = plannedFailure(req.method, url.pathname)
|
||||
@@ -640,7 +766,10 @@ export async function startFakeStripe() {
|
||||
res.writeHead(200, { 'Content-Type': 'application/pdf' })
|
||||
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.end(JSON.stringify(payload))
|
||||
})
|
||||
@@ -659,6 +788,29 @@ export async function startFakeStripe() {
|
||||
payouts,
|
||||
invoiceItems,
|
||||
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
|
||||
* pending_update bleibt das Abo wie bei Stripe unveraendert (und traegt pending_update), bis
|
||||
|
||||
Reference in new issue
Block a user