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

+201
View File
@@ -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` }
}