From da43ee2b413681dc00efd31cb40462200e3412c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:44:10 +0000 Subject: [PATCH] =?UTF-8?q?Shop=20D1:=20Connect-Dienst=20(Konto,=20Onboard?= =?UTF-8?q?ing,=20Checkout,=20Erstattung,=20Webhook-Pr=C3=BCfung)=20und=20?= =?UTF-8?q?Fake-Stripe=20f=C3=BCr=20Connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/services/shop/connect.js | 159 +++++++++++++++++++++ test/shopConnect.test.js | 242 ++++++++++++++++++++++++++++++++ test/support/fakeStripe.js | 158 ++++++++++++++++++++- 3 files changed, 556 insertions(+), 3 deletions(-) create mode 100644 server/services/shop/connect.js create mode 100644 test/shopConnect.test.js diff --git a/server/services/shop/connect.js b/server/services/shop/connect.js new file mode 100644 index 0000000..a84401a --- /dev/null +++ b/server/services/shop/connect.js @@ -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 +} diff --git a/test/shopConnect.test.js b/test/shopConnect.test.js new file mode 100644 index 0000000..4b42447 --- /dev/null +++ b/test/shopConnect.test.js @@ -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') +}) diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index b25df45..8ea8bc3 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -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