Rechnungen: Stripe-Rechnungen im Kundenportal anzeigen (Abo, Funktionen, Projektrechnungen)

Der Tab "Rechnungen" las nur InvoiceNinja. Seit dem Finanzen-Livegang laufen Rechnungen
aber ueber Stripe; die bezahlte Abo-Rechnung eines Kunden fehlte deshalb im Portal.

- /api/invoices fuehrt Stripe (alle Stripe-Kunden des Kunden: Abo-Spiegel und Suche nach
  metadata.webklarCustomerId) und InvoiceNinja zusammen, neueste zuerst; Entwuerfe und
  0-EUR-Rechnungen bleiben verborgen. Faellt eine Quelle aus, kommt der Rest mit incomplete.
- /api/invoices/:id/pdf laedt Stripe-PDFs frisch ueber den Server, nur fuer eigene Rechnungen
  (sonst 404) und nur von *.stripe.com.
- Seite: Status-Farben fuer Bezahlt/Offen/Ueberfaellig/Storniert, "Ansehen" statt
  "Ansehen / Zahlen" bei bezahlten Rechnungen, Hinweis bei unvollstaendiger Liste; der
  Abo-Hinweis verweist nur noch fuer die Zahlungsart auf "Abo verwalten & Rechnungen".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-02 09:33:22 +00:00
1 parent df8b952b81
commit ff7fe35c06
6 files changed
+606 -34

No files matched your search

+23 -6
View File
@@ -15,8 +15,10 @@ import http from 'node:http'
* GET /v1/invoices/:id/lines blättert (limit, starting_after);
* POST /v1/invoices/create_preview liefert st.invoicePreview (Objekt, oder Funktion des Aufruf-Bodys).
* Listen (Finanzen): GET /v1/invoices (expand data.customer ersetzt customer durch das Objekt aus st.customers,
* sofern vorhanden) und GET /v1/payouts (st.payouts) blaettern wie Stripe mit limit (hoechstens 100) und
* starting_after samt has_more; die Reihenfolge ist die Einfuegereihenfolge der Map.
* sofern vorhanden; customer=cus_... nur die Rechnungen dieses Kunden) und GET /v1/payouts (st.payouts) blaettern wie
* Stripe mit limit (hoechstens 100) und starting_after samt has_more; die Reihenfolge ist die Einfuegereihenfolge der Map.
* PDF-Links (Kunden-Tab Rechnungen): GET /pdf/invoices/:id liefert fuer eine bekannte Rechnung ein kleines PDF
* (application/pdf), sonst 404; failNext gilt auch hier. Tests setzen invoice_pdf auf `${st.url}/pdf/invoices/<id>`.
*
* Rechnungen schreiben (Finanzen, Projektrechnungen): POST /v1/invoices (Entwurf; send_invoice braucht
* days_until_due), POST /v1/invoiceitems (mit invoice=... als Zeile mit quantity und unit_amount_decimal,
@@ -277,9 +279,13 @@ export async function startFakeStripe() {
}
if (method === 'GET' && path === '/v1/invoices') {
const expandCustomer = [...query.entries()].some(([k, v]) => k.startsWith('expand') && v === 'data.customer')
const all = [...invoices.values()].map((inv) =>
expandCustomer && typeof inv.customer === 'string' && customers.has(inv.customer) ? { ...inv, customer: customers.get(inv.customer) } : inv
)
// Wie Stripe: customer=cus_... liefert nur die Rechnungen dieses Kunden
const customerFilter = query.get('customer')
const all = [...invoices.values()]
.filter((inv) => !customerFilter || (typeof inv.customer === 'string' ? inv.customer : inv.customer?.id) === customerFilter)
.map((inv) =>
expandCustomer && typeof inv.customer === 'string' && customers.has(inv.customer) ? { ...inv, customer: customers.get(inv.customer) } : inv
)
return pageOf(all, query, '/v1/invoices')
}
if (method === 'GET' && path === '/v1/payouts') {
@@ -623,7 +629,18 @@ export async function startFakeStripe() {
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body), ...(idempotencyKey ? { idempotencyKey } : {}) })
const delay = plannedDelay(req.method, url.pathname)
if (delay) await new Promise((resolve) => setTimeout(resolve, delay))
const [status, payload] = plannedFailure(req.method, url.pathname) || route(req.method, url.pathname, url.searchParams, body)
const failure = plannedFailure(req.method, url.pathname)
// PDF einer Rechnung (invoice_pdf zeigt in Tests hierher): kleines PDF statt JSON
const pdfMatch = !failure && req.method === 'GET' && url.pathname.match(/^\/pdf\/invoices\/([^/]+)$/)
if (pdfMatch) {
if (!invoices.has(pdfMatch[1])) {
res.writeHead(404, { 'Content-Type': 'text/plain' })
return res.end('Not found')
}
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)
res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` })
res.end(JSON.stringify(payload))
})