Finanzen: Rechnung senden ohne Doppelanlage (E-Mail vorab, erneut senden, Summengrenze)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
05e3478229
commit
3cedb5feef
2 files changed
+137
-15
No files matched your search
@@ -109,11 +109,13 @@ 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)
|
||||
@@ -134,7 +136,7 @@ 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 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) {
|
||||
@@ -148,6 +150,9 @@ function validateItems(items) {
|
||||
}
|
||||
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. */
|
||||
@@ -178,17 +183,30 @@ async function readInvoice(invoiceId) {
|
||||
return mapInvoice(raw, { today: berlinDate() })
|
||||
}
|
||||
|
||||
// Finalisieren ohne Auto-Advance (die Rechnung bleibt in unserer Hand), dann per E-Mail senden
|
||||
// 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) {
|
||||
await stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false })
|
||||
await stripe.invoices.sendInvoice(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 oder
|
||||
* einem Kunden, der noch Lead/verloren ist.
|
||||
* 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)
|
||||
@@ -198,6 +216,8 @@ export async function createProjectInvoice({ customerId, items, daysUntilDue = D
|
||||
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)
|
||||
@@ -241,14 +261,25 @@ export async function createProjectInvoice({ customerId, items, daysUntilDue = D
|
||||
}
|
||||
}
|
||||
|
||||
/** Entwurf finalisieren (ohne Auto-Advance) und senden. Nur Projekt-Entwuerfe, sonst status 409. */
|
||||
/**
|
||||
* 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)
|
||||
if (invoice.status !== 'entwurf') throw httpError(409, 'Nur Entwürfe können gesendet werden.')
|
||||
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 {
|
||||
await finalizeAndSend(getStripe(), invoice.id)
|
||||
if (isDraft) await finalizeAndSend(getStripe(), invoice.id)
|
||||
else await getStripe().invoices.sendInvoice(invoice.id)
|
||||
return await readInvoice(invoice.id)
|
||||
} finally {
|
||||
invalidateFinanceCache()
|
||||
|
||||
Reference in new issue
Block a user