import { config } from '../../config.js' import { getStripe, getOrCreateStripeCustomer, subscriptionIdOfInvoice } from '../stripeBilling.js' import { getDocument } from '../appwriteAdmin.js' import { addDays, berlinDate, daysBetween } from './dates.js' import { getSettings } from './settings.js' /** * Stripe-Rechnungen und -Auszahlungen fuer die Finanzen. Rechnungen und Auszahlungen werden nicht * gespiegelt: sie kommen bei jeder Anfrage von Stripe, hoechstens alle 60 Sekunden (Zwischenspeicher je Art). * Die Rohdaten liegen im Zwischenspeicher, gemappt wird bei jedem Aufruf neu: so stimmt "ueberfaellig" auch, * wenn um Mitternacht ein neuer Tag beginnt, und ein Aufrufer kann den Zwischenspeicher nicht veraendern. * Projektrechnungen werden hier auch geschrieben (anlegen, senden, stornieren, Entwurf loeschen, siehe unten); * jede Schreibfunktion leert danach den Zwischenspeicher. */ const CACHE_MS = 60_000 // Rueckblick: Rechnungen der letzten 400 Tage (reicht fuer ein volles Jahr samt Nachzuegler) const LOOKBACK_DAYS = 400 const MAX_INVOICES = 1000 // Stripe-Status -> Status nach aussen; "open" wird je nach Faelligkeit zu "offen" oder "ueberfaellig" const STATUS_DE = { draft: 'entwurf', open: 'offen', paid: 'bezahlt', void: 'storniert', uncollectible: 'uneinbringlich' } // Je Art ein Eintrag { at, promise }; die Zusage (statt der Daten) wird gemerkt, damit gleichzeitige Aufrufe // dieselbe Stripe-Anfrage teilen. Ein Fehler wird nicht zwischengespeichert. const caches = { invoices: null, payouts: null } function cached(kind, force, load) { const entry = caches[kind] if (!force && entry && Date.now() - entry.at < CACHE_MS) return entry.promise const fresh = { at: Date.now(), promise: null } fresh.promise = load().catch((err) => { if (caches[kind] === fresh) caches[kind] = null throw err }) caches[kind] = fresh return fresh.promise } /** Zwischenspeicher leeren (nach jeder Aenderung an Rechnungen oder Auszahlungen, damit die Ansicht stimmt). */ export function invalidateFinanceCache() { caches.invoices = null caches.payouts = null } // Stripe liefert Zeitpunkte als Unix-Sekunden; die Finanzen rechnen mit Berliner Kalendertagen const ymdOfUnix = (seconds) => (Number.isFinite(seconds) ? berlinDate(new Date(seconds * 1000)) : null) /** Eine rohe Stripe-Rechnung (Kunde moeglichst expandiert) in die deutsche Form der Finanzen; today = YYYY-MM-DD. */ export function mapInvoice(raw, { today }) { const customer = raw.customer && typeof raw.customer === 'object' ? raw.customer : null const metadata = raw.metadata || {} const dueDate = ymdOfUnix(raw.due_date) let status = STATUS_DE[raw.status] || raw.status if (raw.status === 'open' && dueDate && dueDate < today) status = 'ueberfaellig' return { id: raw.id, number: raw.number || '', status, customerName: raw.customer_name || customer?.name || '', customerEmail: raw.customer_email || customer?.email || '', webklarCustomerId: metadata.webklarCustomerId || customer?.metadata?.webklarCustomerId || '', createdDate: ymdOfUnix(raw.created), dueDate, totalCents: raw.total ?? 0, remainingCents: raw.amount_remaining ?? 0, paidCents: raw.amount_paid ?? 0, // Bei einer Bankzuordnung zaehlt der Tag, an dem das Geld auf dem Konto ankam, nicht der der Stripe-Buchung paidDate: metadata.bankBookingDate || ymdOfUnix(raw.status_transitions?.paid_at), overdueDays: status === 'ueberfaellig' ? daysBetween(dueDate, today) : 0, source: subscriptionIdOfInvoice(raw) ? 'abo' : 'projekt', hostedUrl: raw.hosted_invoice_url || null, pdfUrl: raw.invoice_pdf || null, outOfBand: Boolean(metadata.bankTransactionId), bankTransactionId: metadata.bankTransactionId || '', } } /** Rechnungen der letzten 400 Tage (hoechstens 1000, neueste zuerst), mit Kunde, auf Deutsch gemappt. */ export async function listInvoices({ force = false } = {}) { const raw = await cached('invoices', force, () => getStripe() .invoices.list({ limit: 100, created: { gte: Math.floor(Date.now() / 1000) - LOOKBACK_DAYS * 86_400 }, expand: ['data.customer'], }) .autoPagingToArray({ limit: MAX_INVOICES }) ) const today = berlinDate() return raw.map((invoice) => mapInvoice(invoice, { today })) } /** Die letzten 50 Stripe-Auszahlungen: { id, amountCents, arrivalDate, status }. */ export async function listPayouts({ force = false } = {}) { const raw = await cached('payouts', force, () => getStripe().payouts.list({ limit: 50 }).then((page) => page.data)) return raw.map((payout) => ({ id: payout.id, amountCents: payout.amount, arrivalDate: ymdOfUnix(payout.arrival_date), status: payout.status, })) } // --------------------------------------------------------------------------- // Projektrechnungen schreiben // --------------------------------------------------------------------------- const MAX_ITEMS = 20 const MAX_DESCRIPTION = 200 const MAX_QUANTITY = 999 const MAX_UNIT_CENTS = 10_000_000 const MAX_TOTAL_CENTS = 99_999_999 const MAX_DAYS_UNTIL_DUE = 60 const DEFAULT_DAYS_UNTIL_DUE = 14 const MAX_MEMO = 1000 const PAYMENT_REFERENCE_NOTE = 'Bitte geben Sie bei Überweisung die Rechnungsnummer als Verwendungszweck an.' const ABO_MESSAGE = 'Abo-Rechnungen verwaltet Stripe.' const NO_EMAIL_MESSAGE = 'Für den Versand fehlt die E-Mail-Adresse des Kunden.' function httpError(status, message) { const err = new Error(message) err.status = status return err } /** Rechnungsfuss: Kleinunternehmer-Hinweis, Bankdaten (falls gesetzt), Hinweis auf den Verwendungszweck. */ export function invoiceFooter(bankDetailsText = '') { const bank = typeof bankDetailsText === 'string' ? bankDetailsText.trim() : '' return [config.stripe.invoiceFooter, bank, PAYMENT_REFERENCE_NOTE].filter(Boolean).join('\n') } const isWholeNumberBetween = (value, min, max) => Number.isInteger(value) && value >= min && value <= max /** Positionen pruefen und bereinigen (Beschreibung ohne Rand-Leerzeichen); Fehler mit status 400. */ function validateItems(items) { if (!Array.isArray(items) || items.length < 1 || items.length > MAX_ITEMS) { throw httpError(400, `Eine Rechnung braucht 1 bis ${MAX_ITEMS} Positionen.`) } const lines = items.map((item, index) => { const label = `Position ${index + 1}` const description = typeof item?.description === 'string' ? item.description.trim() : '' if (description.length < 1 || description.length > MAX_DESCRIPTION) { throw httpError(400, `${label}: Die Beschreibung braucht 1 bis ${MAX_DESCRIPTION} Zeichen.`) } if (!isWholeNumberBetween(item.quantity, 1, MAX_QUANTITY)) { throw httpError(400, `${label}: Die Menge muss eine ganze Zahl von 1 bis ${MAX_QUANTITY} sein.`) } if (!isWholeNumberBetween(item.unitAmountCents, 1, MAX_UNIT_CENTS)) { throw httpError(400, `${label}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`) } return { description, quantity: item.quantity, unitAmountCents: item.unitAmountCents } }) const totalCents = lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0) if (totalCents > MAX_TOTAL_CENTS) throw httpError(400, 'Rechnungssumme zu hoch.') return lines } /** Kunde aus Appwrite; nur feste Kunden (nicht lead/lost) bekommen Rechnungen. */ async function loadBillableCustomer(customerId) { if (typeof customerId !== 'string' || !customerId) throw httpError(400, 'Kunde nicht gefunden.') let customer try { customer = await getDocument(config.collections.customers, customerId) } catch (err) { if (err.status === 404) throw httpError(400, 'Kunde nicht gefunden.') throw err } const stage = String(customer.customerStatus || '').trim().toLowerCase() if (stage === 'lead' || stage === 'lost') throw httpError(400, 'Rechnungen nur für feste Kunden.') return customer } /** Eine Rechnung frisch von Stripe (mit Kunde), gemappt; unbekannte Rechnung -> status 404. */ async function readInvoice(invoiceId) { if (typeof invoiceId !== 'string' || !/^in_\w+$/.test(invoiceId)) throw httpError(404, 'Rechnung nicht gefunden.') let raw try { raw = await getStripe().invoices.retrieve(invoiceId, { expand: ['customer'] }) } catch (err) { if (err?.statusCode === 404 || err?.code === 'resource_missing') throw httpError(404, 'Rechnung nicht gefunden.') throw err } return mapInvoice(raw, { today: berlinDate() }) } // Finalisieren ohne Auto-Advance (die Rechnung bleibt in unserer Hand), dann per E-Mail senden. Scheitert nur das // Senden, ist die Rechnung schon festgeschrieben (offen, mit Nummer): der Fehler (status 502) nennt sie, damit der // Aufrufer sie erneut senden kann (sendInvoiceNow), statt eine zweite anzulegen. async function finalizeAndSend(stripe, invoiceId) { const finalized = await stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false }) try { await stripe.invoices.sendInvoice(invoiceId) } catch (cause) { const number = finalized.number || '' const err = httpError(502, `Rechnung ${number} wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.`) err.invoiceId = invoiceId err.invoiceNumber = number err.cause = cause throw err } } /** * Projektrechnung bei Stripe anlegen: Entwurf mit den Positionen (Menge und Einzelpreis je Position), Zahlungsziel * in Tagen, Rechnungsfuss mit Kleinunternehmer-Hinweis und den Bankdaten aus den Einstellungen (bankDetailsText, siehe * settings.js). Mit senden=true wird sie sofort finalisiert und per E-Mail gesendet (offen), sonst bleibt sie ein * Entwurf. Fehler mit status 400 bei ungueltigen Eingaben (auch Summe ueber 99.999.999 Cent), einem Kunden, der noch * Lead/verloren ist, oder fehlender E-Mail-Adresse bei senden=true (alles vor dem ersten Stripe-Aufruf, ebenso ein * Fehler beim Lesen der Einstellungen). Scheitert nur das Senden nach dem Festschreiben: status 502 mit invoiceId und * invoiceNumber (die offene Rechnung bleibt, erneut senden mit sendInvoiceNow). */ export async function createProjectInvoice({ customerId, items, daysUntilDue = DEFAULT_DAYS_UNTIL_DUE, memo = '', senden = false, createdBy }) { const lines = validateItems(items) if (!isWholeNumberBetween(daysUntilDue, 0, MAX_DAYS_UNTIL_DUE)) { throw httpError(400, `Das Zahlungsziel muss eine ganze Zahl von 0 bis ${MAX_DAYS_UNTIL_DUE} Tagen sein.`) } const note = typeof memo === 'string' ? memo.trim() : '' if (note.length > MAX_MEMO) throw httpError(400, `Der Vermerk darf höchstens ${MAX_MEMO} Zeichen lang sein.`) const customer = await loadBillableCustomer(customerId) // Ohne Adresse lehnt Stripe das Senden erst nach dem Festschreiben ab: lieber vorab pruefen if (senden === true && !String(customer.email || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE) const { bankDetailsText } = await getSettings() const stripe = getStripe() const stripeCustomerId = await getOrCreateStripeCustomer(customer) let invoiceId = null try { const draft = await stripe.invoices.create({ customer: stripeCustomerId, collection_method: 'send_invoice', days_until_due: daysUntilDue, auto_advance: false, pending_invoice_items_behavior: 'exclude', currency: 'eur', footer: invoiceFooter(bankDetailsText), description: note || undefined, metadata: { webklarCustomerId: customer.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500) }, }) invoiceId = draft.id // der Reihe nach, damit die Positionen in der eingegebenen Reihenfolge auf der Rechnung stehen for (const line of lines) { await stripe.invoiceItems.create({ customer: stripeCustomerId, invoice: invoiceId, description: line.description, quantity: line.quantity, unit_amount_decimal: String(line.unitAmountCents), currency: 'eur', }) } } catch (err) { // Halbfertigen Entwurf nicht liegen lassen; scheitert auch das, bleibt er als Entwurf sichtbar und loeschbar if (invoiceId) await stripe.invoices.del(invoiceId).catch(() => {}) invalidateFinanceCache() throw err } try { if (senden === true) await finalizeAndSend(stripe, invoiceId) return await readInvoice(invoiceId) } finally { invalidateFinanceCache() } } /** * Projektrechnung senden: ein Entwurf wird finalisiert (ohne Auto-Advance) und gesendet; eine offene oder ueberfaellige * wird nur erneut gesendet (Erinnerung, kein Finalisieren). Fehler: 409 bei Abo-Rechnungen (verwaltet Stripe) und bei * bezahlten/stornierten/uneinbringlichen, 400 ohne E-Mail-Adresse des Kunden, 404 unbekannt. Scheitert beim Entwurf * nur das Senden nach dem Festschreiben: status 502 mit invoiceId und invoiceNumber (die Rechnung ist dann offen und * kann erneut gesendet werden). */ export async function sendInvoiceNow(invoiceId) { const invoice = await readInvoice(invoiceId) // Ein Abo-Entwurf gehoert Stripe: Finalisieren mit auto_advance=false wuerde den Einzug des Abos anhalten if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE) const isDraft = invoice.status === 'entwurf' if (!isDraft && invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') { throw httpError(409, 'Diese Rechnung kann nicht mehr gesendet werden.') } if (!String(invoice.customerEmail || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE) try { if (isDraft) await finalizeAndSend(getStripe(), invoice.id) else await getStripe().invoices.sendInvoice(invoice.id) return await readInvoice(invoice.id) } finally { invalidateFinanceCache() } } /** Offene oder ueberfaellige Projektrechnung stornieren (Stripe "void"), sonst status 409. */ export async function voidInvoice(invoiceId) { const invoice = await readInvoice(invoiceId) if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE) if (invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') throw httpError(409, 'Nur offene Rechnungen können storniert werden.') try { await getStripe().invoices.voidInvoice(invoice.id) return await readInvoice(invoice.id) } finally { invalidateFinanceCache() } } /** Projekt-Entwurf bei Stripe loeschen, sonst status 409 (nur Entwuerfe lassen sich loeschen). */ export async function deleteDraft(invoiceId) { const invoice = await readInvoice(invoiceId) if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE) if (invoice.status !== 'entwurf') throw httpError(409, 'Nur Entwürfe können gelöscht werden.') try { await getStripe().invoices.del(invoice.id) } finally { invalidateFinanceCache() } }