204 lines
9.0 KiB
JavaScript
204 lines
9.0 KiB
JavaScript
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. `projekt` ist der Projektname aus den Stripe-Metadaten (Projektrechnungen aus dem
|
|
* WOMS), sonst leer.
|
|
*/
|
|
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),
|
|
projekt: raw.metadata?.projektName || '',
|
|
}
|
|
}
|
|
|
|
/** 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` }
|
|
}
|