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:
1 parent
df8b952b81
commit
ff7fe35c06
6 files changed
+606
-34
No files matched your search
+16
-12
@@ -588,41 +588,45 @@ async function loadCustomerInvoices() {
|
||||
const body = document.getElementById('invoices-body')
|
||||
if (!body) return
|
||||
try {
|
||||
// Stripe (Abo, Funktionen, Projektrechnungen) und InvoiceNinja (Altbestand), neueste zuerst
|
||||
const data = await api('/api/invoices')
|
||||
const invoices = data.invoices || []
|
||||
// Zaehler in der Seitenleiste: Rechnungen mit offenem Betrag
|
||||
const openCount = (data.invoices || []).filter((i) => Number(i.balance) > 0).length
|
||||
const openCount = invoices.filter((i) => Number(i.balance) > 0).length
|
||||
const badge = document.getElementById('invoices-badge')
|
||||
if (badge) {
|
||||
badge.textContent = openCount
|
||||
badge.classList.toggle('hidden', !openCount)
|
||||
}
|
||||
if (!data.linked) {
|
||||
body.innerHTML = '<p class="muted">Für dein Konto sind noch keine Rechnungen hinterlegt.</p>'
|
||||
return
|
||||
}
|
||||
if (!data.invoices.length) {
|
||||
body.innerHTML = '<p class="muted">Es liegen noch keine Rechnungen vor.</p>'
|
||||
// Eine der Quellen hat nicht geantwortet: sagen, statt Rechnungen still wegzulassen
|
||||
const gap = data.incomplete
|
||||
? '<p class="kb-note">Deine Rechnungen konnten gerade nicht vollständig geladen werden. Bitte versuche es später noch einmal.</p>'
|
||||
: ''
|
||||
if (!invoices.length) {
|
||||
body.innerHTML = gap || '<p class="muted">Es liegen noch keine Rechnungen vor.</p>'
|
||||
return
|
||||
}
|
||||
const pill = (i) => (i.overdue ? 'pill-red' : i.paid ? 'pill-ok' : i.cancelled ? 'pill-muted' : 'pill-warn')
|
||||
const hasPdf = (i) => i.hasPdf !== false
|
||||
// Klassen je Spalte: in der App-Ansicht wird jede Zeile zur Karte (kundenbereich.css)
|
||||
body.innerHTML = `
|
||||
<div class="kb-table-wrap"><table class="simple-table kb-invoices">
|
||||
<thead><tr><th>Nr.</th><th>Datum</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
${data.invoices.map((i) => `
|
||||
${invoices.map((i) => `
|
||||
<tr class="${Number(i.balance) > 0 ? 'is-open' : 'is-paid'}">
|
||||
<td class="inv-no">${escapeHtml(i.number)}</td>
|
||||
<td class="inv-date">${fmtDateShort(i.date)}</td>
|
||||
<td class="inv-amount">${fmtEuro(i.amount)}</td>
|
||||
<td class="inv-open">${fmtEuro(i.balance)}</td>
|
||||
<td class="inv-status"><span class="pill ${i.overdue ? 'pill-red' : i.statusId === 4 ? 'pill-ok' : 'pill-warn'}">${escapeHtml(i.status)}</span></td>
|
||||
<td class="inv-status"><span class="pill ${pill(i)}">${escapeHtml(i.status)}</span></td>
|
||||
<td class="inv-actions" style="white-space:nowrap">
|
||||
${i.portalUrl ? `<a class="btn-link" href="${escapeAttr(i.portalUrl)}" target="_blank" rel="noreferrer">Ansehen / Zahlen</a><span class="inv-sep" aria-hidden="true"> · </span>` : ''}
|
||||
<a class="btn-link" href="/api/invoices/${escapeAttr(i.id)}/pdf">PDF</a>
|
||||
${i.portalUrl ? `<a class="btn-link" href="${escapeAttr(i.portalUrl)}" target="_blank" rel="noreferrer">${Number(i.balance) > 0 ? 'Ansehen / Zahlen' : 'Ansehen'}</a>${hasPdf(i) ? '<span class="inv-sep" aria-hidden="true"> · </span>' : ''}` : ''}
|
||||
${hasPdf(i) ? `<a class="btn-link" href="/api/invoices/${encodeURIComponent(i.id)}/pdf">PDF</a>` : ''}
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table></div>`
|
||||
</table></div>${gap}`
|
||||
} catch (err) {
|
||||
body.innerHTML = `<p class="muted">Rechnungen konnten nicht geladen werden: ${escapeHtml(err.message)}</p>`
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
<p>Deine Rechnungen ansehen, bezahlen und herunterladen.</p>
|
||||
</header>
|
||||
<div class="kb-panel" id="invoices-body"><p class="kb-empty">Lade…</p></div>
|
||||
<p class="kb-note hidden" id="invoices-abo-note">Rechnungen zum Wartungs-Abo findest du über dein <button type="button" class="kb-link" data-open-account>Profilbild</button> oben rechts unter „Abo verwalten & Rechnungen“.</p>
|
||||
<p class="kb-note hidden" id="invoices-abo-note">Die Zahlungsart für dein Abo änderst du über dein <button type="button" class="kb-link" data-open-account>Profilbild</button> oben rechts unter „Abo verwalten & Rechnungen“.</p>
|
||||
</section>
|
||||
|
||||
<!-- Chat -->
|
||||
|
||||
@@ -6,11 +6,15 @@ import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js'
|
||||
import { getCustomerActivity } from '../services/customerActivity.js'
|
||||
import { customerOrder, customerWorkEntries } from '../services/customerProjectView.js'
|
||||
import { fetchInvoicePdf, getInvoiceRaw, listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||
import { CustomerInvoiceError, TRY_LATER, isStripeInvoiceId, listCustomerInvoices, stripeInvoicePdf } from '../services/customerInvoices.js'
|
||||
import { fetchInvoicePdf, getInvoiceRaw } from '../services/invoiceNinja.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/** Rechnungen des eingeloggten Kunden (InvoiceNinja, sofern verknuepft). */
|
||||
/**
|
||||
* Rechnungen des eingeloggten Kunden: Stripe (Abo, Funktionen, Projektrechnungen aus dem WOMS) und InvoiceNinja
|
||||
* (Altbestand, sofern verknuepft), neueste zuerst. incomplete: eine der Quellen hat nicht geantwortet.
|
||||
*/
|
||||
router.get('/invoices', requireSession, async (req, res) => {
|
||||
try {
|
||||
const customer = await getDocument(
|
||||
@@ -20,18 +24,37 @@ router.get('/invoices', requireSession, async (req, res) => {
|
||||
if (!customer) {
|
||||
return res.status(404).json({ error: 'Kunde nicht gefunden' })
|
||||
}
|
||||
if (!customer.invoiceNinjaClientId) {
|
||||
return res.json({ linked: false, invoices: [] })
|
||||
}
|
||||
const invoices = await listInvoicesForClient(customer.invoiceNinjaClientId)
|
||||
return res.json({ linked: true, invoices })
|
||||
return res.json(await listCustomerInvoices(customer))
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Rechnungen konnten nicht geladen werden' })
|
||||
if (err instanceof CustomerInvoiceError) return res.status(err.status).json({ error: err.message })
|
||||
console.error('[rechnungen] Liste fehlgeschlagen:', err?.type || err?.name || 'Error', err?.statusCode ?? err?.status ?? '')
|
||||
return res.status(500).json({ error: TRY_LATER })
|
||||
}
|
||||
})
|
||||
|
||||
/** Rechnungs-PDF des eingeloggten Kunden (Download ueber Server-Proxy). */
|
||||
/** PDF an den Browser (Download), nie zwischengespeichert. */
|
||||
function sendPdf(res, pdf, filename) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Cache-Control': 'no-store',
|
||||
})
|
||||
return res.end(pdf)
|
||||
}
|
||||
|
||||
/** Rechnungs-PDF des eingeloggten Kunden (Download ueber Server-Proxy; InvoiceNinja oder Stripe). */
|
||||
router.get('/invoices/:invoiceId/pdf', requireSession, async (req, res) => {
|
||||
// Stripe-Rechnung (in_...): Besitz ueber die Stripe-Kunden des Kunden, PDF frisch von Stripe
|
||||
if (isStripeInvoiceId(req.params.invoiceId)) {
|
||||
try {
|
||||
const { pdf, filename } = await stripeInvoicePdf(getSessionCustomerId(req), req.params.invoiceId)
|
||||
return sendPdf(res, pdf, filename)
|
||||
} catch (err) {
|
||||
if (err instanceof CustomerInvoiceError) return res.status(err.status).json({ error: err.message })
|
||||
console.error('[rechnungen] PDF-Abruf fehlgeschlagen:', err?.type || err?.name || 'Error', err?.statusCode ?? err?.status ?? '')
|
||||
return res.status(500).json({ error: 'PDF konnte nicht geladen werden' })
|
||||
}
|
||||
}
|
||||
try {
|
||||
const customer = await getDocument(
|
||||
config.collections.customers,
|
||||
@@ -53,12 +76,7 @@ router.get('/invoices/:invoiceId/pdf', requireSession, async (req, res) => {
|
||||
}
|
||||
|
||||
const pdf = await fetchInvoicePdf(invitationKey)
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="Rechnung-${invoice.number || invoice.id}.pdf"`,
|
||||
'Cache-Control': 'no-store',
|
||||
})
|
||||
return res.end(pdf)
|
||||
return sendPdf(res, pdf, `Rechnung-${invoice.number || invoice.id}.pdf`)
|
||||
} catch (err) {
|
||||
return res.status(err.status || 500).json({ error: err.message || 'PDF konnte nicht geladen werden' })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { config } from '../config.js'
|
||||
import { getStripe, isStripeConfigured, listSubscriptionsForCustomer } from './stripeBilling.js'
|
||||
import { listInvoicesForClient } from './invoiceNinja.js'
|
||||
import { berlinDate } from './finance/dates.js'
|
||||
|
||||
/**
|
||||
* Rechnungen fuer den Kunden-Tab "Rechnungen": Stripe (Abo-Rechnungen, Einrichtung von Funktionen und die
|
||||
* Projektrechnungen, die das WOMS seit dem 01.10.2026 ueber Stripe schreibt) und InvoiceNinja (Altbestand),
|
||||
* neueste zuerst. Stripe wird bei jedem Aufruf frisch gefragt: so stimmt der Status gleich nach einer Zahlung, und
|
||||
* die Links zur Rechnungsseite sind laut Stripe ab dem Abruf mindestens 10 Tage gueltig
|
||||
* (docs.stripe.com/invoicing/hosted-invoice-page, "Rechnungs-URLs").
|
||||
*/
|
||||
|
||||
// Zeitgrenze je Stripe-Anfrage, auch fuer Folgeseiten: das SDK wartet sonst bis zu 80 Sekunden und wiederholt
|
||||
const READ_OPTIONS = { timeout: 10_000, maxNetworkRetries: 1 }
|
||||
const MAX_INVOICES_PER_STRIPE_CUSTOMER = 500
|
||||
// Wie Appwrite-IDs (a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich): nur solche Kennungen gehen in die Stripe-Suche
|
||||
const SAFE_CUSTOMER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
||||
const STRIPE_INVOICE_ID = /^in_\w+$/
|
||||
// Festgeschriebene Rechnungen; Entwuerfe sieht der Kunde nicht
|
||||
const ISSUED = new Set(['open', 'paid', 'uncollectible', 'void'])
|
||||
const PDF_TIMEOUT_MS = 20_000
|
||||
|
||||
/** Fehler mit HTTP-Status und einer Meldung, die der Kunde sehen darf. */
|
||||
export class CustomerInvoiceError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message)
|
||||
this.name = 'CustomerInvoiceError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
const NOT_FOUND = 'Rechnung nicht gefunden'
|
||||
// Die Seite stellt "Rechnungen konnten nicht geladen werden: " voran (app.js, loadCustomerInvoices)
|
||||
export const TRY_LATER = 'Bitte versuche es später noch einmal.'
|
||||
const PDF_FAILED = 'PDF konnte nicht geladen werden'
|
||||
|
||||
/** Kennung einer Stripe-Rechnung (in_...)? Sonst ist es eine InvoiceNinja-Rechnung. */
|
||||
export const isStripeInvoiceId = (id) => typeof id === 'string' && STRIPE_INVOICE_ID.test(id)
|
||||
|
||||
/**
|
||||
* Stripe-Kunden eines Portal-Kunden: aus den Abo-Spiegeln (stripeCustomerId) und per Suche nach
|
||||
* metadata.webklarCustomerId (so legt getOrCreateStripeCustomer jeden Stripe-Kunden an, auch fuer Kunden ohne Abo).
|
||||
*/
|
||||
export async function stripeCustomerIdsFor(customerId) {
|
||||
if (!SAFE_CUSTOMER_ID.test(String(customerId || ''))) return new Set()
|
||||
const [subscriptions, found] = await Promise.all([
|
||||
listSubscriptionsForCustomer(customerId),
|
||||
getStripe().customers.search({ query: `metadata['webklarCustomerId']:'${customerId}'`, limit: 20 }, READ_OPTIONS),
|
||||
])
|
||||
return new Set([...subscriptions.map((sub) => sub.stripeCustomerId), ...found.data.map((c) => c.id)].filter(Boolean))
|
||||
}
|
||||
|
||||
const ymdOfUnix = (seconds) => (Number.isFinite(seconds) && seconds > 0 ? berlinDate(new Date(seconds * 1000)) : '')
|
||||
|
||||
/** Fuer den Kunden sichtbar: festgeschrieben und mit Betrag (0-€-Rechnungen, z. B. Probezeit oder voller Rabatt, nicht). */
|
||||
const visibleToCustomer = (raw) => ISSUED.has(raw.status) && (raw.total ?? 0) > 0
|
||||
|
||||
/**
|
||||
* Stripe-Rechnung in der Form des Kunden-Tabs (wie sanitizeInvoice fuer InvoiceNinja, Betraege in Euro). Offen sind
|
||||
* "open" und "uncollectible" (Stripe nimmt die Zahlung weiter an); ueberfaellig, wenn die Faelligkeit vor dem
|
||||
* Berliner Tag today (YYYY-MM-DD) liegt.
|
||||
*/
|
||||
export function customerStripeInvoice(raw, { today = berlinDate() } = {}) {
|
||||
const outstanding = raw.status === 'open' || raw.status === 'uncollectible'
|
||||
const balanceCents = outstanding ? raw.amount_remaining ?? 0 : 0
|
||||
const dueDate = ymdOfUnix(raw.due_date)
|
||||
const overdue = balanceCents > 0 && Boolean(dueDate) && dueDate < today
|
||||
const paid = raw.status === 'paid'
|
||||
const cancelled = raw.status === 'void'
|
||||
return {
|
||||
id: raw.id,
|
||||
source: 'stripe',
|
||||
number: raw.number || '',
|
||||
// Rechnungsdatum wie auf dem PDF: effective_at, sonst Festschreibung, sonst Anlage
|
||||
date: ymdOfUnix(raw.effective_at || raw.status_transitions?.finalized_at || raw.created),
|
||||
dueDate,
|
||||
amount: (raw.total ?? 0) / 100,
|
||||
balance: balanceCents / 100,
|
||||
status: overdue ? 'Überfällig' : paid ? 'Bezahlt' : cancelled ? 'Storniert' : 'Offen',
|
||||
paid,
|
||||
cancelled,
|
||||
overdue,
|
||||
portalUrl: raw.hosted_invoice_url || '',
|
||||
hasPdf: Boolean(raw.invoice_pdf),
|
||||
}
|
||||
}
|
||||
|
||||
/** Sichtbare Stripe-Rechnungen eines Portal-Kunden (aller seiner Stripe-Kunden). */
|
||||
export async function listStripeInvoicesForCustomer(customerId) {
|
||||
const stripeCustomerIds = await stripeCustomerIdsFor(customerId)
|
||||
const stripe = getStripe()
|
||||
const lists = await Promise.all(
|
||||
[...stripeCustomerIds].map((id) =>
|
||||
stripe.invoices.list({ customer: id, limit: 100 }, READ_OPTIONS).autoPagingToArray({ limit: MAX_INVOICES_PER_STRIPE_CUSTOMER })
|
||||
)
|
||||
)
|
||||
const today = berlinDate()
|
||||
return lists
|
||||
.flat()
|
||||
.filter(visibleToCustomer)
|
||||
.map((raw) => customerStripeInvoice(raw, { today }))
|
||||
}
|
||||
|
||||
/** InvoiceNinja-Rechnung (sanitizeInvoice) mit denselben Zusatzfeldern wie die Stripe-Rechnungen. */
|
||||
const fromInvoiceNinja = (invoice) => ({
|
||||
...invoice,
|
||||
source: 'invoiceninja',
|
||||
paid: invoice.statusId === 4,
|
||||
cancelled: invoice.statusId === 5 || invoice.statusId === 6,
|
||||
hasPdf: true,
|
||||
})
|
||||
|
||||
/** Fehlerart fuers Protokoll, nie die Meldung. */
|
||||
const logFailure = (what, customerId, err) =>
|
||||
console.error(`[rechnungen] ${what} fuer Kunde ${customerId} nicht geladen:`, err?.type || err?.name || 'Error', err?.statusCode ?? err?.status ?? '')
|
||||
|
||||
/**
|
||||
* Alle Rechnungen eines Kunden (Appwrite-Kundendokument), neueste zuerst: { invoices, incomplete }. Faellt eine
|
||||
* Quelle aus, kommen die der anderen mit incomplete: true; fallen alle befragten Quellen aus, gibt es einen Fehler.
|
||||
*/
|
||||
export async function listCustomerInvoices(customer) {
|
||||
const sources = []
|
||||
if (isStripeConfigured()) {
|
||||
sources.push({ name: 'Stripe-Rechnungen', load: () => listStripeInvoicesForCustomer(customer.$id) })
|
||||
}
|
||||
if (customer.invoiceNinjaClientId) {
|
||||
sources.push({
|
||||
name: 'InvoiceNinja-Rechnungen',
|
||||
load: async () => (await listInvoicesForClient(customer.invoiceNinjaClientId)).map(fromInvoiceNinja),
|
||||
})
|
||||
}
|
||||
const results = await Promise.allSettled(sources.map((source) => source.load()))
|
||||
const invoices = []
|
||||
let failed = 0
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'fulfilled') invoices.push(...result.value)
|
||||
else {
|
||||
failed += 1
|
||||
logFailure(sources[i].name, customer.$id, result.reason)
|
||||
}
|
||||
})
|
||||
if (failed && failed === sources.length) throw new CustomerInvoiceError(502, TRY_LATER)
|
||||
invoices.sort((a, b) => String(b.date).localeCompare(String(a.date)) || String(b.number).localeCompare(String(a.number)))
|
||||
return { invoices, incomplete: failed > 0 }
|
||||
}
|
||||
|
||||
/** Der PDF-Link muss zu Stripe fuehren (https, *.stripe.com); in Tests auch zum Stripe-Nachbau (config.stripe.apiBase). */
|
||||
function stripePdfUrl(value) {
|
||||
let url
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (url.protocol === 'https:' && (url.hostname === 'stripe.com' || url.hostname.endsWith('.stripe.com'))) return url
|
||||
if (config.stripe.apiBase && url.origin === new URL(config.stripe.apiBase).origin) return url
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF einer Stripe-Rechnung des Kunden: { pdf: Buffer, filename }. Unbekannte, fremde und nicht festgeschriebene
|
||||
* Rechnungen: status 404, ohne zu verraten, ob es die Rechnung gibt. Den PDF-Link liefert Stripe mit der frisch
|
||||
* geladenen Rechnung (also nie abgelaufen); der Server laedt die Datei, damit der Download wie bei InvoiceNinja aus
|
||||
* dem Portal kommt.
|
||||
*/
|
||||
export async function stripeInvoicePdf(customerId, invoiceId) {
|
||||
if (!isStripeInvoiceId(invoiceId)) throw new CustomerInvoiceError(404, NOT_FOUND)
|
||||
const [stripeCustomerIds, raw] = await Promise.all([
|
||||
stripeCustomerIdsFor(customerId),
|
||||
getStripe()
|
||||
.invoices.retrieve(invoiceId, {}, READ_OPTIONS)
|
||||
.catch((err) => {
|
||||
if (err?.statusCode === 404 || err?.code === 'resource_missing') return null
|
||||
throw err
|
||||
}),
|
||||
])
|
||||
const owner = raw && (typeof raw.customer === 'string' ? raw.customer : raw.customer?.id)
|
||||
if (!raw || !stripeCustomerIds.has(owner) || !ISSUED.has(raw.status) || !raw.invoice_pdf) {
|
||||
throw new CustomerInvoiceError(404, NOT_FOUND)
|
||||
}
|
||||
const url = stripePdfUrl(raw.invoice_pdf)
|
||||
if (!url) {
|
||||
console.error(`[rechnungen] PDF-Link der Rechnung ${raw.id} fuehrt nicht zu Stripe`)
|
||||
throw new CustomerInvoiceError(502, PDF_FAILED)
|
||||
}
|
||||
let response
|
||||
try {
|
||||
response = await fetch(url, { signal: AbortSignal.timeout(PDF_TIMEOUT_MS) })
|
||||
} catch (err) {
|
||||
logFailure(`PDF der Rechnung ${raw.id}`, customerId, err)
|
||||
throw new CustomerInvoiceError(502, PDF_FAILED)
|
||||
}
|
||||
if (!response.ok) {
|
||||
logFailure(`PDF der Rechnung ${raw.id}`, customerId, { name: 'HTTP', status: response.status })
|
||||
throw new CustomerInvoiceError(502, PDF_FAILED)
|
||||
}
|
||||
const pdf = Buffer.from(await response.arrayBuffer())
|
||||
const name = String(raw.number || raw.id).replace(/[^A-Za-z0-9._-]/g, '_')
|
||||
return { pdf, filename: `Rechnung-${name}.pdf` }
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import http from 'node:http'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
|
||||
/**
|
||||
* Kunden-Tab "Rechnungen" (2026-10-02): Stripe-Rechnungen (Abo, Funktionen, Projektrechnungen aus dem WOMS) stehen
|
||||
* neben den InvoiceNinja-Rechnungen. Vorher zeigte der Tab nur InvoiceNinja, eine bezahlte Abo-Rechnung fehlte.
|
||||
*/
|
||||
|
||||
// Kleiner InvoiceNinja-Nachbau: Rechnungsliste je Client, eine Rechnung mit Einladung, PDF ueber den Einladungsschluessel
|
||||
let ninjaDown = false
|
||||
const ninjaInvoices = [
|
||||
{ id: 'inv_in_1', client_id: 'inc1', number: '2026-001', date: '', due_date: '', amount: 168.79, balance: 0, status_id: 4, is_deleted: false, invitations: [{ key: 'key_in_1' }] },
|
||||
{ id: 'inv_in_entwurf', client_id: 'inc1', number: '', date: '', due_date: '', amount: 50, balance: 50, status_id: 1, is_deleted: false, invitations: [] },
|
||||
]
|
||||
const ninja = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, 'http://127.0.0.1')
|
||||
const json = (status, body) => {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
if (ninjaDown) return json(500, { message: 'Nachbau: InvoiceNinja nicht erreichbar' })
|
||||
if (url.pathname === '/api/v1/invoices') {
|
||||
return json(200, { data: ninjaInvoices.filter((inv) => inv.client_id === url.searchParams.get('client_id')) })
|
||||
}
|
||||
const one = url.pathname.match(/^\/api\/v1\/invoices\/([^/]+)$/)
|
||||
if (one) {
|
||||
const inv = ninjaInvoices.find((i) => i.id === one[1])
|
||||
return inv ? json(200, { data: inv }) : json(404, { message: 'Not found' })
|
||||
}
|
||||
if (/^\/client\/invoice\/key_in_1\/download_pdf$/.test(url.pathname)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/pdf' })
|
||||
return res.end('%PDF-1.4\n% InvoiceNinja-Nachbau\n%%EOF\n')
|
||||
}
|
||||
return json(404, { message: 'Not found' })
|
||||
})
|
||||
await new Promise((resolve) => ninja.listen(0, '127.0.0.1', resolve))
|
||||
|
||||
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
|
||||
const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions') } })
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
process.env.INVOICE_NINJA_URL = `http://127.0.0.1:${ninja.address().port}`
|
||||
process.env.INVOICE_NINJA_API_TOKEN = 'in-test-token'
|
||||
const { startTestApp, rawGet } = await import('./support/testApp.js')
|
||||
const { customers, customerCookie, seedFixtures } = await import('./support/fixtures.js')
|
||||
const { default: customerDataRoutes } = await import('../server/routes/customerData.js')
|
||||
const { customerStripeInvoice, isStripeInvoiceId, stripeCustomerIdsFor } = await import('../server/services/customerInvoices.js')
|
||||
const { berlinDate } = await import('../server/services/finance/dates.js')
|
||||
|
||||
const app = await startTestApp({ '/api': customerDataRoutes })
|
||||
after(() => Promise.all([app.close(), aw.close(), st.close(), new Promise((resolve) => ninja.close(resolve))]))
|
||||
seedFixtures(aw)
|
||||
// c1: Abo sub_h (Stripe-Kunde cus_c1) und dazu ein InvoiceNinja-Client (Altbestand)
|
||||
aw.seed('customers', { ...customers.c1, invoiceNinjaClientId: 'inc1' })
|
||||
|
||||
const DAY = 86_400
|
||||
const nowUnix = () => Math.floor(Date.now() / 1000)
|
||||
// Berliner Mittag vor n Tagen (Unix-Sekunden), damit das Rechnungsdatum eindeutig ist
|
||||
const daysAgo = (n) => Math.floor(new Date(`${berlinDate(new Date(Date.now() - n * DAY * 1000))}T12:00:00+02:00`).getTime() / 1000)
|
||||
const ymdDaysAgo = (n) => berlinDate(new Date(daysAgo(n) * 1000))
|
||||
|
||||
const stripeInvoice = (id, customer, over = {}) => ({
|
||||
id,
|
||||
object: 'invoice',
|
||||
customer,
|
||||
number: id.toUpperCase(),
|
||||
status: 'paid',
|
||||
collection_method: 'send_invoice',
|
||||
created: daysAgo(5),
|
||||
effective_at: daysAgo(5),
|
||||
status_transitions: { finalized_at: daysAgo(5), paid_at: daysAgo(5) },
|
||||
due_date: null,
|
||||
total: 1000,
|
||||
amount_due: 1000,
|
||||
amount_paid: 1000,
|
||||
amount_remaining: 0,
|
||||
hosted_invoice_url: `https://invoice.stripe.com/i/${id}`,
|
||||
invoice_pdf: `${st.url}/pdf/invoices/${id}`,
|
||||
metadata: {},
|
||||
lines: { object: 'list', data: [], has_more: false },
|
||||
...over,
|
||||
})
|
||||
|
||||
function seedStripe() {
|
||||
st.invoices.clear()
|
||||
st.customers.clear()
|
||||
// cus_c1 haengt am Abo und traegt die Metadaten; cus_c1b nur per Suche (z. B. Projektrechnung ohne Abo-Kunden)
|
||||
st.customers.set('cus_c1', { id: 'cus_c1', object: 'customer', metadata: { webklarCustomerId: 'c1' } })
|
||||
st.customers.set('cus_c1b', { id: 'cus_c1b', object: 'customer', metadata: { webklarCustomerId: 'c1' } })
|
||||
st.customers.set('cus_c2', { id: 'cus_c2', object: 'customer', metadata: { webklarCustomerId: 'c2' } })
|
||||
st.customers.set('cus_c3', { id: 'cus_c3', object: 'customer', metadata: { webklarCustomerId: 'c3' } })
|
||||
const all = [
|
||||
// Wartungs-Abo, bezahlt (wie Martins erste Abo-Rechnung)
|
||||
stripeInvoice('in_c1_abo', 'cus_c1', {
|
||||
number: 'H96T-0004',
|
||||
collection_method: 'charge_automatically',
|
||||
effective_at: daysAgo(3),
|
||||
total: 2500,
|
||||
amount_due: 2500,
|
||||
amount_paid: 2500,
|
||||
parent: { subscription_details: { subscription: 'sub_h' } },
|
||||
}),
|
||||
stripeInvoice('in_c1_offen', 'cus_c1', {
|
||||
status: 'open',
|
||||
effective_at: daysAgo(1),
|
||||
due_date: nowUnix() + 18 * DAY,
|
||||
total: 12000,
|
||||
amount_due: 12000,
|
||||
amount_paid: 0,
|
||||
amount_remaining: 12000,
|
||||
}),
|
||||
stripeInvoice('in_c1_alt', 'cus_c1', {
|
||||
status: 'open',
|
||||
effective_at: daysAgo(40),
|
||||
due_date: daysAgo(26),
|
||||
total: 9900,
|
||||
amount_due: 9900,
|
||||
amount_paid: 0,
|
||||
amount_remaining: 9900,
|
||||
}),
|
||||
stripeInvoice('in_c1_storno', 'cus_c1', { status: 'void', effective_at: daysAgo(10), total: 5000, amount_paid: 0, amount_remaining: 5000 }),
|
||||
stripeInvoice('in_c1_uneinbringlich', 'cus_c1', { status: 'uncollectible', effective_at: daysAgo(20), total: 3000, amount_paid: 0, amount_remaining: 3000 }),
|
||||
// nie sichtbar: Entwurf (ohne Nummer und Links) und 0-€-Rechnung (Probezeit)
|
||||
stripeInvoice('in_c1_entwurf', 'cus_c1', { status: 'draft', number: null, effective_at: null, hosted_invoice_url: null, invoice_pdf: null }),
|
||||
stripeInvoice('in_c1_null', 'cus_c1', { effective_at: daysAgo(4), total: 0, amount_due: 0, amount_paid: 0 }),
|
||||
stripeInvoice('in_c1b_projekt', 'cus_c1b', { number: 'WK-0001', effective_at: daysAgo(2), total: 45000, amount_due: 45000, amount_paid: 45000 }),
|
||||
stripeInvoice('in_c2_abo', 'cus_c2', { effective_at: daysAgo(1) }),
|
||||
stripeInvoice('in_c3_projekt', 'cus_c3', { status: 'open', due_date: nowUnix() + 10 * DAY, amount_paid: 0, amount_remaining: 1000 }),
|
||||
]
|
||||
for (const invoice of all) st.invoices.set(invoice.id, invoice)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
ninjaDown = false
|
||||
st.calls.length = 0
|
||||
seedStripe()
|
||||
})
|
||||
|
||||
const listFor = (customerId) => rawGet('/api/invoices', { cookie: customerCookie(customerId) })
|
||||
const ids = (body) => body.invoices.map((i) => i.id)
|
||||
|
||||
test('ohne Anmeldung: 401 fuer Liste und PDF', async () => {
|
||||
assert.equal((await rawGet('/api/invoices')).status, 401)
|
||||
assert.equal((await rawGet('/api/invoices/in_c1_abo/pdf')).status, 401)
|
||||
})
|
||||
|
||||
test('Liste: Stripe-Rechnungen aller Stripe-Kunden des Kunden und InvoiceNinja, neueste zuerst', async () => {
|
||||
const { status, body } = await listFor('c1')
|
||||
assert.equal(status, 200)
|
||||
assert.equal(body.incomplete, false)
|
||||
assert.deepEqual(ids(body), [
|
||||
'in_c1_offen',
|
||||
'in_c1b_projekt',
|
||||
'in_c1_abo',
|
||||
'in_c1_storno',
|
||||
'in_c1_uneinbringlich',
|
||||
'in_c1_alt',
|
||||
'inv_in_1',
|
||||
])
|
||||
// Jeder Stripe-Kunde genau einmal gefragt (cus_c1 steht im Abo und in der Suche), nie ein fremder
|
||||
const asked = st.calls.filter((c) => c.method === 'GET' && c.path === '/v1/invoices').map((c) => c.query.customer)
|
||||
assert.deepEqual(asked.sort(), ['cus_c1', 'cus_c1b'])
|
||||
})
|
||||
|
||||
test('Liste: Felder einer bezahlten Abo-Rechnung (Betraege in Euro, Link zur Rechnungsseite, PDF)', async () => {
|
||||
const { body } = await listFor('c1')
|
||||
const abo = body.invoices.find((i) => i.id === 'in_c1_abo')
|
||||
assert.deepEqual(abo, {
|
||||
id: 'in_c1_abo',
|
||||
source: 'stripe',
|
||||
number: 'H96T-0004',
|
||||
date: ymdDaysAgo(3),
|
||||
dueDate: '',
|
||||
amount: 25,
|
||||
balance: 0,
|
||||
status: 'Bezahlt',
|
||||
paid: true,
|
||||
cancelled: false,
|
||||
overdue: false,
|
||||
portalUrl: 'https://invoice.stripe.com/i/in_c1_abo',
|
||||
hasPdf: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('Liste: offen, ueberfaellig, storniert, uneinbringlich und InvoiceNinja-Altbestand', async () => {
|
||||
const { body } = await listFor('c1')
|
||||
const by = Object.fromEntries(body.invoices.map((i) => [i.id, i]))
|
||||
assert.equal(by.in_c1_offen.status, 'Offen')
|
||||
assert.equal(by.in_c1_offen.balance, 120)
|
||||
assert.equal(by.in_c1_offen.overdue, false)
|
||||
assert.equal(by.in_c1_alt.status, 'Überfällig')
|
||||
assert.equal(by.in_c1_alt.overdue, true)
|
||||
assert.equal(by.in_c1_alt.balance, 99)
|
||||
assert.equal(by.in_c1_storno.status, 'Storniert')
|
||||
assert.equal(by.in_c1_storno.cancelled, true)
|
||||
assert.equal(by.in_c1_storno.balance, 0)
|
||||
// Stripe nimmt die Zahlung einer uneinbringlichen Rechnung weiter an: fuer den Kunden offen
|
||||
assert.equal(by.in_c1_uneinbringlich.status, 'Offen')
|
||||
assert.equal(by.in_c1_uneinbringlich.balance, 30)
|
||||
assert.equal(by.inv_in_1.source, 'invoiceninja')
|
||||
assert.equal(by.inv_in_1.paid, true)
|
||||
assert.equal(by.inv_in_1.status, 'Bezahlt')
|
||||
// Entwuerfe (Stripe und InvoiceNinja) und 0-€-Rechnungen sieht der Kunde nicht
|
||||
assert.equal(by.in_c1_entwurf, undefined)
|
||||
assert.equal(by.in_c1_null, undefined)
|
||||
assert.equal(by.inv_in_entwurf, undefined)
|
||||
})
|
||||
|
||||
test('Liste: Kunde ohne Abo findet seine Projektrechnung ueber die Stripe-Suche; fremde Rechnungen nie', async () => {
|
||||
const c3 = await listFor('c3')
|
||||
assert.equal(c3.status, 200)
|
||||
assert.deepEqual(ids(c3.body), ['in_c3_projekt'])
|
||||
const c2 = await listFor('c2')
|
||||
assert.deepEqual(ids(c2.body), ['in_c2_abo'])
|
||||
})
|
||||
|
||||
test('Liste: faellt Stripe aus, kommen die InvoiceNinja-Rechnungen mit incomplete', async () => {
|
||||
st.failNext({ method: 'GET', path: '/v1/customers/search', status: 400 })
|
||||
const { status, body } = await listFor('c1')
|
||||
assert.equal(status, 200)
|
||||
assert.equal(body.incomplete, true)
|
||||
assert.deepEqual(ids(body), ['inv_in_1'])
|
||||
})
|
||||
|
||||
test('Liste: faellt InvoiceNinja aus, kommen die Stripe-Rechnungen mit incomplete', async () => {
|
||||
ninjaDown = true
|
||||
const { status, body } = await listFor('c1')
|
||||
assert.equal(status, 200)
|
||||
assert.equal(body.incomplete, true)
|
||||
assert.equal(ids(body).length, 6)
|
||||
assert.ok(ids(body).every((id) => id.startsWith('in_')))
|
||||
})
|
||||
|
||||
test('Liste: fallen alle befragten Quellen aus, gibt es 502 mit fester Meldung (ohne Doppelung auf der Seite)', async () => {
|
||||
ninjaDown = true
|
||||
st.failNext({ method: 'GET', path: '/v1/customers/search', status: 400 })
|
||||
const both = await listFor('c1')
|
||||
assert.equal(both.status, 502)
|
||||
// Die Seite stellt "Rechnungen konnten nicht geladen werden: " voran
|
||||
assert.equal(both.body.error, 'Bitte versuche es später noch einmal.')
|
||||
// c3 hat kein InvoiceNinja: Stripe ist die einzige Quelle
|
||||
st.failNext({ method: 'GET', path: '/v1/invoices', status: 400 })
|
||||
const only = await listFor('c3')
|
||||
assert.equal(only.status, 502)
|
||||
})
|
||||
|
||||
test('PDF: eigene Stripe-Rechnung kommt ueber den Server als Download', async () => {
|
||||
const res = await rawGet('/api/invoices/in_c1_abo/pdf', { cookie: customerCookie('c1') })
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(res.headers.get('content-type'), 'application/pdf')
|
||||
assert.equal(res.headers.get('content-disposition'), 'attachment; filename="Rechnung-H96T-0004.pdf"')
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store')
|
||||
assert.match(res.body, /^%PDF-1\.4/)
|
||||
assert.ok(st.calls.some((c) => c.method === 'GET' && c.path === '/pdf/invoices/in_c1_abo'))
|
||||
})
|
||||
|
||||
test('PDF: fremde, unbekannte und Entwurfs-Rechnungen geben 404, ohne das PDF zu laden', async () => {
|
||||
for (const id of ['in_c2_abo', 'in_gibtsnicht', 'in_c1_entwurf']) {
|
||||
const res = await rawGet(`/api/invoices/${id}/pdf`, { cookie: customerCookie('c1') })
|
||||
assert.equal(res.status, 404, id)
|
||||
assert.equal(res.body.error, 'Rechnung nicht gefunden', id)
|
||||
}
|
||||
assert.equal(st.calls.filter((c) => c.path.startsWith('/pdf/')).length, 0)
|
||||
})
|
||||
|
||||
test('PDF: ein Link, der nicht zu Stripe fuehrt, wird nicht geladen (502)', async () => {
|
||||
st.invoices.set('in_c1_fremd', stripeInvoice('in_c1_fremd', 'cus_c1', { invoice_pdf: 'https://example.invalid/rechnung.pdf' }))
|
||||
const res = await rawGet('/api/invoices/in_c1_fremd/pdf', { cookie: customerCookie('c1') })
|
||||
assert.equal(res.status, 502)
|
||||
assert.equal(res.body.error, 'PDF konnte nicht geladen werden')
|
||||
})
|
||||
|
||||
test('PDF: liefert Stripe das PDF nicht, gibt es 502 mit fester Meldung', async () => {
|
||||
st.failNext({ method: 'GET', path: /^\/pdf\/invoices\//, status: 500 })
|
||||
const res = await rawGet('/api/invoices/in_c1_abo/pdf', { cookie: customerCookie('c1') })
|
||||
assert.equal(res.status, 502)
|
||||
assert.equal(res.body.error, 'PDF konnte nicht geladen werden')
|
||||
})
|
||||
|
||||
test('PDF: InvoiceNinja-Rechnungen laufen weiter ueber InvoiceNinja', async () => {
|
||||
const res = await rawGet('/api/invoices/inv_in_1/pdf', { cookie: customerCookie('c1') })
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(res.headers.get('content-disposition'), 'attachment; filename="Rechnung-2026-001.pdf"')
|
||||
assert.match(res.body, /InvoiceNinja-Nachbau/)
|
||||
// fremder Kunde (c3 hat keinen InvoiceNinja-Client): 404
|
||||
assert.equal((await rawGet('/api/invoices/inv_in_1/pdf', { cookie: customerCookie('c3') })).status, 404)
|
||||
})
|
||||
|
||||
test('customerStripeInvoice: Berliner Rechnungsdatum und Faelligkeit gegen den Berliner Tag', () => {
|
||||
const at = (iso) => Math.floor(new Date(iso).getTime() / 1000)
|
||||
const raw = {
|
||||
id: 'in_x',
|
||||
number: 'WK-0002',
|
||||
status: 'open',
|
||||
// 22:30 UTC = 00:30 MESZ am Folgetag
|
||||
effective_at: at('2026-09-29T22:30:00Z'),
|
||||
due_date: at('2026-10-01T10:00:00Z'),
|
||||
total: 1999,
|
||||
amount_remaining: 1999,
|
||||
hosted_invoice_url: null,
|
||||
invoice_pdf: null,
|
||||
}
|
||||
const sameDay = customerStripeInvoice(raw, { today: '2026-10-01' })
|
||||
assert.equal(sameDay.date, '2026-09-30')
|
||||
assert.equal(sameDay.dueDate, '2026-10-01')
|
||||
assert.equal(sameDay.overdue, false)
|
||||
assert.equal(sameDay.status, 'Offen')
|
||||
assert.equal(sameDay.amount, 19.99)
|
||||
assert.equal(sameDay.portalUrl, '')
|
||||
assert.equal(sameDay.hasPdf, false)
|
||||
const nextDay = customerStripeInvoice(raw, { today: '2026-10-02' })
|
||||
assert.equal(nextDay.overdue, true)
|
||||
assert.equal(nextDay.status, 'Überfällig')
|
||||
// ohne effective_at: Festschreibung, sonst Anlage
|
||||
assert.equal(customerStripeInvoice({ ...raw, effective_at: null, status_transitions: { finalized_at: at('2026-09-01T10:00:00Z') } }).date, '2026-09-01')
|
||||
assert.equal(customerStripeInvoice({ ...raw, effective_at: null, created: at('2026-08-15T10:00:00Z') }).date, '2026-08-15')
|
||||
})
|
||||
|
||||
test('isStripeInvoiceId und Stripe-Suche nur mit sicheren Kunden-IDs', async () => {
|
||||
assert.equal(isStripeInvoiceId('in_1UL6LPBsRA1AOdnegGQ6D6IS'), true)
|
||||
assert.equal(isStripeInvoiceId('Opnel5aKBz'), false)
|
||||
assert.equal(isStripeInvoiceId('in_../x'), false)
|
||||
assert.equal(isStripeInvoiceId('in_'), false)
|
||||
const before = st.calls.length
|
||||
assert.deepEqual([...(await stripeCustomerIdsFor("c1'] OR metadata['x"))], [])
|
||||
assert.equal(st.calls.length, before)
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user