313 lines
14 KiB
JavaScript
313 lines
14 KiB
JavaScript
import { config } from '../../config.js'
|
|
import { getStripe, getOrCreateStripeCustomer, subscriptionIdOfInvoice } from '../stripeBilling.js'
|
|
import { getDocument } from '../appwriteAdmin.js'
|
|
import { addDays, berlinDate, daysBetween } from './dates.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 Bankdaten. 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). 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, bankDetailsText = '' }) {
|
|
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 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()
|
|
}
|
|
}
|