Finanzen: Projektrechnungen in Stripe anlegen, senden, stornieren, Entwurf loeschen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
db243f50bb
commit
05e3478229
4 files changed
+748
-4
No files matched your search
@@ -1,11 +1,15 @@
|
||||
import { getStripe, subscriptionIdOfInvoice } from '../stripeBilling.js'
|
||||
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 (nur lesen). Rechnungen und Auszahlungen werden nicht
|
||||
* 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
|
||||
@@ -96,3 +100,182 @@ export async function listPayouts({ force = false } = {}) {
|
||||
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_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.'
|
||||
|
||||
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.`)
|
||||
}
|
||||
return 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 }
|
||||
})
|
||||
}
|
||||
|
||||
/** 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
|
||||
async function finalizeAndSend(stripe, invoiceId) {
|
||||
await stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false })
|
||||
await stripe.invoices.sendInvoice(invoiceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 oder
|
||||
* einem Kunden, der noch Lead/verloren ist.
|
||||
*/
|
||||
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)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/** Entwurf finalisieren (ohne Auto-Advance) und senden. Nur Projekt-Entwuerfe, sonst status 409. */
|
||||
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)
|
||||
if (invoice.status !== 'entwurf') throw httpError(409, 'Nur Entwürfe können gesendet werden.')
|
||||
try {
|
||||
await finalizeAndSend(getStripe(), 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()
|
||||
}
|
||||
}
|
||||
@@ -431,7 +431,7 @@ export async function grantCreditForInvoice(invoice, { fresh = false } = {}) {
|
||||
// Checkout / Kundenportal / Kuendigung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getOrCreateStripeCustomer(customer) {
|
||||
export async function getOrCreateStripeCustomer(customer) {
|
||||
const stripe = getStripe()
|
||||
const subs = await listSubscriptionsForCustomer(customer.$id)
|
||||
const known = subs.find((s) => s.stripeCustomerId)?.stripeCustomerId
|
||||
|
||||
Reference in new issue
Block a user