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` } }