From 38bd56fe8be03e471f93077564b21038f6754bed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:56:04 +0000 Subject: [PATCH] Shop D2: Kundenrouten Stripe-Konto (einrichten, Link, aktualisieren) mit Sperre gegen Doppelklick Co-Authored-By: Claude Opus 5.5 --- server/routes/shop.js | 76 ++++++++++- test/shopStripeRoutes.test.js | 245 ++++++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 test/shopStripeRoutes.test.js diff --git a/server/routes/shop.js b/server/routes/shop.js index 99fb22e..73a8b03 100644 --- a/server/routes/shop.js +++ b/server/routes/shop.js @@ -8,6 +8,7 @@ import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.j import { createWriteLimiter } from '../services/writeLimiter.js' import { anfrageAnsicht, ensureShopConfig, leseShopConfig, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js' import { shopFuerKunde } from '../services/shop/schalter.js' +import { kontoAnlegen, kontoStatus, onboardingLink, statusAusKonto } from '../services/shop/connect.js' import { uploadSlot } from './chat.js' import { istVollstaendig, speichereEinstellungen } from '../services/shop/einstellungen.js' import { bereitschaft, istGeoeffnet, ladeShop, leereCache } from '../services/shop/kontext.js' @@ -24,7 +25,7 @@ import { /** * Kunden-Schnittstelle des Webshops: /api/shop (Spezifikation 2026-10-05-webshop-design.md). Stand: Anfrage (B1) und - * Verwaltung (C7): Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder. Stripe (D2) und Bestellungen (E) kommen dazu. + * Verwaltung (C7): Status, Einstellungen, Artikel, Rechtstexte, Oeffnen, Bilder. Stripe-Konto (D2) und Bestellungen (E) kommen dazu. * * Jede Route: Session, projectId (Query oder Body), eigenes Projekt. Die Verwaltungs-Routen laufen ueber mitShop: Schalter * (409 nicht verfuegbar), Funktion im Zustand setup|active (lesend auch disabled), shopConfig vorhanden (setup|active legt es an). @@ -50,11 +51,14 @@ const MELDUNG = { zuVieleBilder: 'Zu viele Bilder in kurzer Zeit. Bitte versuch es später wieder.', bildFehlt: 'Bild nicht gefunden.', eingabe: 'Die Eingabe fehlt oder ist unlesbar.', + stripeKundenansicht: 'In der Kundenansicht kann das Stripe-Konto nicht eingerichtet werden.', + stripeFern: 'Stripe ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.', + keinKonto: 'Es ist noch kein Stripe-Konto angelegt.', } /** Fehler des Kunden (4xx/503 ohne Appwrite-Typ) wie sie sind, alles andere 500 ohne Rohtext. */ function fail(res, err) { - const own = ((err?.status >= 400 && err.status < 500) || err?.status === 503) && !err.type + const own = ((err?.status >= 400 && err.status < 500) || err?.status === 502 || err?.status === 503) && !err.type if (!own) { console.error(`[shop] ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${String(err?.message ?? '').slice(0, 200)}`.trim()) return res.status(500).json({ error: 'Interner Serverfehler' }) @@ -449,6 +453,74 @@ router.post('/oeffnen', requireSession, nurKunde, limitWrites, route(async (req, res.json({ schalter: req.body.offen, geoeffnet: await istGeoeffnet({ doc, shop: neu, project: null }, { produkte }), bereitschaft: await bereitschaft({ doc, shop: neu, project: null }, { produkte }) }) })) +// ---- Stripe-Konto (D2) + +/** Nur der Kunde selbst richtet sein Stripe-Konto ein (weder Kundenansicht noch "Ansehen als"). */ +const nurKundeStripe = (req, res, next) => { + if (isKundenansicht(req.session) || req.session?.viewAs) return res.status(403).json({ error: MELDUNG.stripeKundenansicht }) + return next() +} + +/** Stripe-Aufruf: jeder Fehler von Stripe wird zu 502 mit festem Text (Stripe-Texte verlassen den Server nie, geloggt wird nur Typ und Code). */ +async function ueberStripe(fn) { + try { + return await fn() + } catch (err) { + if (err?.status >= 400 && err.status < 500 && !err.type) throw err + console.error(`[shop] Stripe ${String(err?.type ?? err?.name ?? 'Fehler').slice(0, 60)} ${String(err?.code ?? '').slice(0, 60)}`.trim()) + throw stop(502, MELDUNG.stripeFern) + } +} + +const speichereStripe = async (shop, felder) => { + await updateDocument(config.collections.shopConfig, shop.id, felder) + leereCache(shop.siteKey) +} + +const stripeRueckUrl = (projectId, was) => `${config.publicBaseUrl}/dashboard.html#webshop?stripe=${was}&projekt=${encodeURIComponent(projectId)}` +const onboardingFuer = (accountId, projectId) => + ueberStripe(() => onboardingLink(accountId, { returnUrl: stripeRueckUrl(projectId, 'zurueck'), refreshUrl: stripeRueckUrl(projectId, 'neu') })) + +// Konto anlegen (einmalig, unter Sperre) und Onboarding-Link liefern +router.post('/stripe/einrichten', requireSession, nurKundeStripe, limitWrites, route(async (req, res) => { + const { shop, customerId, projectId, project } = await mitShop(req, { schreiben: true, zustaende: ['setup', 'active'] }) + const accountId = await withLocks([`shop-stripe:${shop.id}`], async () => { + const frisch = await frischerShop(shop.id) + if (frisch.stripeAccountId) return frisch.stripeAccountId + const customer = await getDocument(config.collections.customers, customerId).catch(() => null) + const neu = await ueberStripe(() => kontoAnlegen({ customer: customer || { $id: customerId, email: req.session?.email }, project })) + let status + try { + status = await ueberStripe(() => kontoStatus(neu)) + } catch { + status = statusAusKonto({}) + } + await speichereStripe(shop, { stripeAccountId: neu, stripeStatus: JSON.stringify(status) }) + return neu + }) + res.json({ url: await onboardingFuer(accountId, projectId) }) +})) + +// Neuer Onboarding-Link (der alte laeuft nach wenigen Minuten ab) +router.post('/stripe/link', requireSession, nurKundeStripe, limitWrites, route(async (req, res) => { + const { shop, projectId } = await mitShop(req, { schreiben: true, zustaende: ['setup', 'active'] }) + if (!shop.stripeAccountId) throw stop(409, MELDUNG.keinKonto, 'kein_konto') + res.json({ url: await onboardingFuer(shop.stripeAccountId, projectId) }) +})) + +// Status bei Stripe neu lesen und speichern +router.post('/stripe/aktualisieren', requireSession, nurKundeStripe, limitWrites, route(async (req, res) => { + const { shop } = await mitShop(req, { schreiben: true, zustaende: ['setup', 'active'] }) + if (!shop.stripeAccountId) throw stop(409, MELDUNG.keinKonto, 'kein_konto') + const status = await ueberStripe(() => kontoStatus(shop.stripeAccountId)) + const alt = shop.stripeStatus + const zahlungsarten = Array.isArray(shop.einstellungen?.zahlungsarten) ? shop.einstellungen.zahlungsarten : ['card'] + const behalteHinweis = typeof alt?.hinweis === 'string' && alt.hinweis !== '' && status.charges_enabled === true && zahlungsarten.some((z) => z === 'klarna' || z === 'sepa') + const neu = behalteHinweis ? { ...status, hinweis: alt.hinweis } : status + await speichereStripe(shop, { stripeStatus: JSON.stringify(neu) }) + res.json({ stripe: stripeAnsicht({ ...shop, stripeStatus: neu }) }) +})) + // ---- Bilder // Bild als rohe Bytes (application/octet-stream), hoechstens 8 MB; die Pruefung des Shops laeuft vor dem Einlesen diff --git a/test/shopStripeRoutes.test.js b/test/shopStripeRoutes.test.js new file mode 100644 index 0000000..0b10ecd --- /dev/null +++ b/test/shopStripeRoutes.test.js @@ -0,0 +1,245 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { SHOP_DATETIMES } from '../server/services/shop/schema.js' + +// Webshop D2: Stripe-Konto einrichten, neuer Onboarding-Link, Status aktualisieren (Fake-Stripe) +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + shopConfig: schemaKeys('shopConfig'), + shopProducts: schemaKeys('shopProducts'), + customerViewLog: schemaKeys('customerViewLog'), + }, + datetimes: SHOP_DATETIMES, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, send } = await import('./support/testApp.js') +const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures, customers } = await import('./support/fixtures.js') +const { default: shopRoutes, resetShopRoutesForTests } = await import('../server/routes/shop.js') +const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') +const sf = await import('../server/services/siteFeatures.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') +const { leereCache } = await import('../server/services/shop/kontext.js') + +const app = await startTestApp({ '/': kundenansichtGuard, '/api/shop': shopRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) + +seedFixtures(aw) +aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Zweiter Shop' }) +aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremdes Projekt' }) + +// Je Test ein frisches Projekt: Kontoanlage ist je Projekt idempotent (Idempotency-Key), alte Konten wuerden sonst wiederkehren +let P = 'pt1' +let zaehler = 0 +const cookie = customerCookie('c1') +const GEDULD = 'Stripe ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.' +const post = (pfad, body = { projectId: P }, opts = { cookie }) => send('POST', `/api/shop/stripe/${pfad}`, { ...opts, body }) +const status = (projectId = P) => send('GET', `/api/shop/status?projectId=${projectId}`, { cookie }) +const plattform = () => st.calls.filter((c) => c.path === '/v1/accounts' || c.path === '/v1/account_links' || c.path.startsWith('/v1/accounts/')) + +async function aktiviere(projectId = P, state = 'active') { + const feature = await sf.ensureFeatureDoc('c1', projectId, 'shop') + await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields(state)) + return sf.getFeatureDoc('c1', projectId, 'shop') +} +const kontenFuer = (projectId) => [...st.accounts.values()].filter((a) => a.metadata?.projectId === projectId) +const shopConfigDoc = (feature) => aw.docs('shopConfig').get(feature.$id) + +beforeEach(async () => { + P = `pn${(zaehler += 1)}` + aw.seed('websiteProjects', { $id: P, customerId: 'c1', projectName: 'Töpferei', liveDomain: 'toepferei.example', archived: false }) + config.shop.mode = 'pilot' + config.shop.pilotEmails = [customers.c1.email] + resetShopRoutesForTests() + for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'customerViewLog']) { + for (const id of [...aw.docs(col).keys()]) await deleteDocument(config.collections[col], id) + } + st.calls.length = 0 + leereCache() +}) + +test('einrichten: legt ein Konto an, speichert Konto-ID und Status, liefert den Onboarding-Link mit Rücksprung-Adressen', async () => { + const feature = await aktiviere() + const res = await post('einrichten') + assert.equal(res.status, 200) + assert.match(res.body.url, /^https:\/\/connect\.stripe\.test\/setup\/acct_/) + assert.deepEqual(Object.keys(res.body), ['url']) + const doc = shopConfigDoc(feature) + assert.match(doc.stripeAccountId, /^acct_/) + assert.equal(kontenFuer(P).length, 1) + const gespeichert = JSON.parse(doc.stripeStatus) + assert.equal(gespeichert.charges_enabled, false) + assert.deepEqual(gespeichert.currently_due, ['external_account']) + // Plattform-Aufrufe ohne Stripe-Account-Kopfzeile, Konto mit Kunden-E-Mail und Projekt in den Metadaten + assert.ok(plattform().length >= 3) + for (const c of plattform()) assert.equal(c.account, undefined, `${c.method} ${c.path} darf keine Stripe-Account-Kopfzeile tragen`) + const anlage = st.calls.find((c) => c.method === 'POST' && c.path === '/v1/accounts') + assert.equal(anlage.body.email, customers.c1.email) + assert.equal(anlage.body['metadata[projectId]'], P) + const link = st.calls.find((c) => c.path === '/v1/account_links') + assert.equal(link.body.account, doc.stripeAccountId) + assert.equal(link.body.return_url, `${config.publicBaseUrl}/dashboard.html#webshop?stripe=zurueck&projekt=${P}`) + assert.equal(link.body.refresh_url, `${config.publicBaseUrl}/dashboard.html#webshop?stripe=neu&projekt=${P}`) + // GET /status zeigt das Konto als verbunden, ohne die Konto-ID + const s = (await status()).body + assert.equal(s.stripe.verbunden, true) + assert.equal(JSON.stringify(s).includes(doc.stripeAccountId), false) +}) + +test('einrichten: zwei parallele Aufrufe (Doppelklick) legen nur ein Konto an', async () => { + const feature = await aktiviere(P, 'setup') + const [a, b] = await Promise.all([post('einrichten'), post('einrichten')]) + assert.deepEqual([a.status, b.status], [200, 200]) + assert.equal(st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/accounts').length, 1) + assert.equal(kontenFuer(P).length, 1) + assert.equal(st.calls.filter((c) => c.path === '/v1/account_links').length, 2) + assert.ok(a.body.url && b.body.url) + // weiterer Aufruf: Konto bleibt dasselbe + const id = shopConfigDoc(feature).stripeAccountId + assert.equal((await post('einrichten')).status, 200) + assert.equal(shopConfigDoc(feature).stripeAccountId, id) + assert.equal(kontenFuer(P).length, 1) +}) + +test('link: braucht ein Konto (409), liefert danach einen neuen Link mit den Rücksprung-Adressen', async () => { + await aktiviere() + const ohne = await post('link') + assert.deepEqual([ohne.status, ohne.body.error, ohne.body.code], [409, 'Es ist noch kein Stripe-Konto angelegt.', 'kein_konto']) + assert.equal((await post('einrichten')).status, 200) + st.calls.length = 0 + const res = await post('link') + assert.equal(res.status, 200) + assert.match(res.body.url, /^https:\/\/connect\.stripe\.test\/setup\//) + assert.equal(st.calls.length, 1) + assert.equal(st.calls[0].account, undefined) + assert.equal(st.calls[0].body.return_url, `${config.publicBaseUrl}/dashboard.html#webshop?stripe=zurueck&projekt=${P}`) + assert.equal(kontenFuer(P).length, 1) +}) + +test('aktualisieren: braucht ein Konto (409), liest den Status neu und speichert ihn', async () => { + const feature = await aktiviere() + assert.equal((await post('aktualisieren')).status, 409) + await post('einrichten') + const id = shopConfigDoc(feature).stripeAccountId + st.setAccount(id, { charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [] } }) + const res = await post('aktualisieren') + assert.equal(res.status, 200) + assert.equal(res.body.stripe.verbunden, true) + assert.equal(res.body.stripe.status.charges_enabled, true) + assert.deepEqual(res.body.stripe.status.currently_due, []) + assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).charges_enabled, true) + assert.equal(JSON.stringify(res.body).includes(id), false) + // gleiche Form wie GET /status + assert.deepEqual((await status()).body.stripe, res.body.stripe) +}) + +test('aktualisieren: Hinweis zu Klarna/SEPA bleibt nur, solange Zahlungen möglich sind und eine der Zahlungsarten gewählt ist', async () => { + const feature = await aktiviere() + await post('einrichten') + const id = shopConfigDoc(feature).stripeAccountId + const setze = async (stripeStatus, zahlungsarten) => { + const e = { zahlungsarten } + await updateDocument(config.collections.shopConfig, feature.$id, { stripeStatus: JSON.stringify(stripeStatus), einstellungen: JSON.stringify(e) }) + leereCache() + } + st.setAccount(id, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } }) + await setze({ charges_enabled: true, hinweis: 'Klarna ist noch nicht freigeschaltet.' }, ['card', 'klarna']) + await post('aktualisieren') + assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).hinweis, 'Klarna ist noch nicht freigeschaltet.') + await setze({ charges_enabled: true, hinweis: 'Klarna ist noch nicht freigeschaltet.' }, ['card']) + await post('aktualisieren') + assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).hinweis, undefined) + st.setAccount(id, { charges_enabled: false }) + await setze({ charges_enabled: true, hinweis: 'Klarna ist noch nicht freigeschaltet.' }, ['card', 'sepa']) + await post('aktualisieren') + assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).hinweis, undefined) +}) + +test('Stripe-Fehler: 502 mit festem Du-Text, nie der Stripe-Text; bei gescheiterter Kontoanlage bleibt kein Konto gespeichert', async () => { + const feature = await aktiviere() + const log = [] + const orig = console.error + console.error = (...a) => log.push(a.join(' ')) + try { + st.failNext({ method: 'POST', path: '/v1/accounts', status: 400, message: 'Geheimer Stripe-Text sk_live_123' }) + const a = await post('einrichten') + assert.deepEqual([a.status, a.body.error], [502, GEDULD]) + assert.equal(JSON.stringify(a.body).includes('Geheim'), false) + assert.equal(shopConfigDoc(feature).stripeAccountId || '', '') + // Konto da, Link scheitert: Konto bleibt gespeichert, 502 + st.failNext({ method: 'POST', path: '/v1/account_links', status: 400, message: 'Geheimer Linktext' }) + const b = await post('einrichten') + assert.deepEqual([b.status, b.body.error], [502, GEDULD]) + assert.match(shopConfigDoc(feature).stripeAccountId, /^acct_/) + st.failNext({ method: 'POST', path: '/v1/account_links', status: 400, message: 'Geheimer Linktext' }) + assert.deepEqual([(await post('link')).status], [502]) + st.failNext({ method: 'GET', path: /^\/v1\/accounts\//, status: 400, message: 'Geheimer Lesetext' }) + const c = await post('aktualisieren') + assert.deepEqual([c.status, c.body.error], [502, GEDULD]) + } finally { + console.error = orig + } + assert.equal(log.some((l) => l.includes('Geheim') || l.includes('sk_live')), false) +}) + +test('Kundenansicht: der Wächter sperrt die Routen (nicht auf der Erlaubnisliste), der Router selbst auch ohne Wächter (Kundenansicht und Ansehen als)', async () => { + const feature = await aktiviere() + for (const pfad of ['einrichten', 'link', 'aktualisieren']) { + const res = await post(pfad, { projectId: P }, { cookie: kundenansichtCookie({ customerId: 'c1' }), headers: { 'X-Kundenansicht': 'a'.repeat(32) } }) + assert.deepEqual([res.status, res.body.code], [403, 'view_blocked'], pfad) + } + const express = (await import('express')).default + const { sessionMiddleware } = await import('../server/middleware/session.js') + const roh = express() + roh.use(sessionMiddleware()) + roh.use(express.json()) + roh.use('/api/shop', shopRoutes) + const server = await new Promise((resolve) => { + const s = roh.listen(0, '127.0.0.1', () => resolve(s)) + }) + try { + const url = `http://127.0.0.1:${server.address().port}` + for (const pfad of ['einrichten', 'link', 'aktualisieren']) { + for (const c of [kundenansichtCookie({ customerId: 'c1' }), viewAsCookie('c1')]) { + const res = await fetch(`${url}/api/shop/stripe/${pfad}`, { method: 'POST', headers: { 'content-type': 'application/json', cookie: c }, body: JSON.stringify({ projectId: P }) }) + assert.equal(res.status, 403, pfad) + assert.equal((await res.json()).error, 'In der Kundenansicht kann das Stripe-Konto nicht eingerichtet werden.') + } + } + } finally { + await new Promise((resolve) => server.close(resolve)) + } + assert.equal(st.calls.length, 0) + assert.equal(shopConfigDoc(feature)?.stripeAccountId || '', '') +}) + +test('Zugang und Zustand: fremdes Projekt 403, ohne Projekt 400, ohne Session 401, nicht eingerichtet/deaktiviert/Schalter aus 409', async () => { + const feature = await aktiviere() + assert.equal((await post('einrichten', { projectId: 'pt3' })).status, 403) + assert.equal((await post('einrichten', {})).status, 400) + assert.equal((await send('POST', '/api/shop/stripe/einrichten', { body: { projectId: P } })).status, 401) + assert.equal((await post('einrichten', { projectId: 'pt2' })).status, 409) + await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('disabled')) + const aus = await post('einrichten') + assert.deepEqual([aus.status, aus.body.code], [409, 'nicht_eingerichtet']) + await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('off')) + assert.equal((await post('aktualisieren')).status, 409) + await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('active')) + config.shop.mode = 'off' + const schalter = await post('link') + assert.deepEqual([schalter.status, schalter.body.code], [409, 'nicht_verfuegbar']) + assert.equal(st.calls.length, 0) +}) + +test('Rate-Limit: die 301. Schreibanfrage in der Stunde wird abgewiesen', async () => { + await aktiviere() + let letzter + for (let i = 0; i < 301; i += 1) letzter = await post('link') + assert.equal(letzter.status, 429) +})