511 lines
26 KiB
JavaScript
511 lines
26 KiB
JavaScript
import { createHash } from 'node:crypto'
|
|
import { config } from '../../config.js'
|
|
import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptionIdOfInvoice } from '../stripeBilling.js'
|
|
import { addDays, berlinDate, daysBetween } from './dates.js'
|
|
import { RECHNUNGS_ARTEN, sperreRechnung } from '../auftraege/konstanten.js'
|
|
import { withLocks } from '../keyedLock.js'
|
|
import { baueRechnung, ladeFestenKunden, buche, gibProjektRechnungFrei, setzeAbAbgerechnet } from '../auftraege/rechnungsbau.js'
|
|
import { createProjektRechnung, updateProjektRechnung } from '../auftraege/projektRechnungenStore.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
|
|
|
|
// Zeitgrenze der Leseanfragen an Stripe: das SDK wartet sonst je Anfrage bis zu 80 Sekunden (und wiederholt), bei bis
|
|
// zu zehn Seiten haengt die Seite minutenlang. Die Grenze gilt je Anfrage, auch fuer die Folgeseiten.
|
|
const DEFAULT_READ_OPTIONS = { timeout: 15_000, maxNetworkRetries: 1 }
|
|
let readOptions = DEFAULT_READ_OPTIONS
|
|
|
|
/** Nur fuer Tests: andere Optionen fuer die Leseanfragen (null stellt die Voreinstellung wieder her). */
|
|
export function setStripeReadOptionsForTests(options) {
|
|
readOptions = options ?? DEFAULT_READ_OPTIONS
|
|
}
|
|
|
|
/** Meldung an die Seite, wenn Stripe nicht antwortet (Übersicht, Rechnungsliste, Schreibzugriffe). */
|
|
export const STRIPE_UNAVAILABLE_MESSAGE = 'Stripe ist gerade nicht erreichbar.'
|
|
|
|
// 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 || '',
|
|
projektId: metadata.projektId || '',
|
|
projektName: metadata.projektName || '',
|
|
art: RECHNUNGS_ARTEN.includes(metadata.art) ? metadata.art : subscriptionIdOfInvoice(raw) ? 'abo' : 'frei',
|
|
auftragId: metadata.auftragId || '',
|
|
auftragNummer: metadata.auftragNummer || '',
|
|
}
|
|
}
|
|
|
|
/** 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, async () => {
|
|
const invoices = await getStripe()
|
|
.invoices.list(
|
|
{
|
|
limit: 100,
|
|
created: { gte: Math.floor(Date.now() / 1000) - LOOKBACK_DAYS * 86_400 },
|
|
expand: ['data.customer'],
|
|
},
|
|
readOptions
|
|
)
|
|
.autoPagingToArray({ limit: MAX_INVOICES })
|
|
// Bei der Obergrenze fehlen aeltere Rechnungen: Zaehler und Altersgruppen waeren zu niedrig, ohne dass es auffaellt
|
|
if (invoices.length >= MAX_INVOICES) console.warn(`[finanzen] Rechnungsliste bei ${MAX_INVOICES} abgeschnitten`)
|
|
return 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 }, readOptions).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_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.'
|
|
const MAX_IDEMPOTENCY_KEY = 100
|
|
const IN_PROGRESS_MESSAGE = 'Diese Rechnung wird gerade schon erstellt.'
|
|
// So lange nach einem Erfolg nimmt der Server dieselbe Rechnung (gleicher Inhalt) nicht noch einmal an
|
|
const DUPLICATE_WINDOW_MS = 30_000
|
|
|
|
/**
|
|
* Fehler dieses Moduls mit HTTP-Status und einer Meldung, die die Seite anzeigen darf (nur diese gibt die Route
|
|
* weiter; Stripe- und Appwrite-Fehler tragen auch status/statusCode, ihre Meldungen bleiben auf dem Server).
|
|
*/
|
|
export class InvoiceError extends Error {
|
|
constructor(status, message) {
|
|
super(message)
|
|
this.name = 'InvoiceError'
|
|
this.status = status
|
|
}
|
|
}
|
|
|
|
const httpError = (status, message) => new InvoiceError(status, message)
|
|
|
|
const CUSTOMER_SYNC_MESSAGE = 'Kundendaten konnten nicht an Stripe übertragen werden.'
|
|
|
|
/**
|
|
* Den Stripe-Kunden auf den Stand der WOMS-Kundendaten bringen (E-Mail, Name, Anschrift), bevor eine Rechnung entsteht:
|
|
* ein frueher angelegter oder ueber die Metadaten gefundener Stripe-Kunde traegt sonst noch die alte E-Mail und
|
|
* Anschrift, und Stripe schreibt beides auf die Rechnung (Empfaengeranschrift, § 14 Abs. 4 Nr. 1 UStG). Scheitert
|
|
* das, entsteht keine Rechnung: status 502 mit fester Meldung (die Ursache bleibt als cause fuers Protokoll).
|
|
*/
|
|
async function syncStripeCustomer(stripe, stripeCustomerId, customer) {
|
|
const fields = stripeCustomerFields(customer)
|
|
if (!fields.email && !fields.name && !fields.address) return
|
|
try {
|
|
await stripe.customers.update(stripeCustomerId, fields)
|
|
} catch (cause) {
|
|
const err = httpError(502, CUSTOMER_SYNC_MESSAGE)
|
|
err.cause = cause
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// Kleinbetragsrechnung (§ 33 UStDV): bis 250 € braucht der Empfaenger keine Anschrift auf der Rechnung. Wert und Text
|
|
// wie im WOMS (financeView.js: SMALL_INVOICE_MAX_CENTS, ADDRESS_INCOMPLETE_TEXT).
|
|
const SMALL_INVOICE_MAX_CENTS = 25_000
|
|
const ADDRESS_INCOMPLETE_MESSAGE = 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.'
|
|
|
|
const hasText = (value) => typeof value === 'string' && value.trim() !== ''
|
|
|
|
/**
|
|
* Adress-Sperre vor dem Senden (§ 14 Abs. 4 Nr. 1 UStG): Fehlt beim WOMS-Kunden Strasse, PLZ oder Ort (nur Leerzeichen
|
|
* zaehlt als fehlend) und liegt die Summe ueber 250 €, gibt es status 409 mit fester Meldung. Gilt nur fuer das Senden
|
|
* (Entwuerfe ohne Versand und erneutes Senden einer schon festgeschriebenen Rechnung bleiben unberuehrt) und wird vor dem
|
|
* ersten Stripe-Schreibzugriff geprueft.
|
|
*/
|
|
function assertAddressForSend(customer, totalCents) {
|
|
const complete = hasText(customer.street) && hasText(customer.postalCode) && hasText(customer.city)
|
|
if (!complete && totalCents > SMALL_INVOICE_MAX_CENTS) throw httpError(409, ADDRESS_INCOMPLETE_MESSAGE)
|
|
}
|
|
|
|
/** 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
|
|
|
|
/** Eine Rechnung frisch von Stripe (mit Kunde), roh; unbekannte Rechnung -> status 404. */
|
|
async function retrieveInvoice(invoiceId) {
|
|
if (typeof invoiceId !== 'string' || !/^in_\w+$/.test(invoiceId)) throw httpError(404, 'Rechnung nicht gefunden.')
|
|
try {
|
|
return 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
|
|
}
|
|
}
|
|
|
|
/** Eine Rechnung frisch von Stripe (mit Kunde), gemappt; unbekannte Rechnung -> status 404. */
|
|
async function readInvoice(invoiceId) {
|
|
return mapInvoice(await retrieveInvoice(invoiceId), { today: berlinDate() })
|
|
}
|
|
|
|
// Buchungen am Projektdatensatz nach einem Stripe-Erfolg: scheitert Appwrite, wird nur protokolliert (siehe buche)
|
|
const PROJEKT_ARTEN = RECHNUNGS_ARTEN.filter((art) => art !== 'frei')
|
|
|
|
async function bucheNummer(invoiceId, number, art) {
|
|
if (!PROJEKT_ARTEN.includes(art) || !number) return
|
|
await buche('Rechnungsnummer buchen', () => updateProjektRechnung(invoiceId, { rechnungsnummer: number }))
|
|
}
|
|
|
|
/** Finalisiert: Rechnungsnummer in den Datensatz; bei einer Schlussrechnung geht die AB auf abgerechnet. */
|
|
async function bucheFinalisiert(invoiceId, number, art, auftragId) {
|
|
if (!PROJEKT_ARTEN.includes(art)) return
|
|
await bucheNummer(invoiceId, number, art)
|
|
if (art === 'schluss' && auftragId) await buche('AB abrechnen', () => setzeAbAbgerechnet(auftragId))
|
|
}
|
|
|
|
/** Storniert oder geloescht: Datensatz inaktiv (Blaetter frei); bei einer Schlussrechnung geht die AB zurueck. */
|
|
async function bucheStorniert(invoiceId, art) {
|
|
if (!PROJEKT_ARTEN.includes(art)) return
|
|
await buche('Rechnung freigeben', () => gibProjektRechnungFrei(invoiceId))
|
|
}
|
|
|
|
// 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, { art = 'frei', auftragId = '' } = {}) {
|
|
const finalized = await stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false })
|
|
const number = finalized.number || ''
|
|
await bucheFinalisiert(invoiceId, number, art, auftragId)
|
|
try {
|
|
await stripe.invoices.sendInvoice(invoiceId)
|
|
} catch (cause) {
|
|
throw invoiceExistsError(`Rechnung ${number} wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.`, invoiceId, number, cause)
|
|
}
|
|
return number
|
|
}
|
|
|
|
/** Kennung des Stripe-Kunden einer rohen Rechnung (expandiert oder als Text). */
|
|
const stripeCustomerIdOf = (raw) => (raw.customer && typeof raw.customer === 'object' ? raw.customer.id : raw.customer)
|
|
|
|
/** Fehler 502 an einer Rechnung, die bei Stripe schon besteht: traegt invoiceId und invoiceNumber (und die Ursache) fuer die Seite. */
|
|
function invoiceExistsError(message, invoiceId, number, cause) {
|
|
const err = httpError(502, message)
|
|
err.invoiceId = invoiceId
|
|
err.invoiceNumber = number
|
|
err.cause = cause
|
|
return err
|
|
}
|
|
|
|
// Schutz vor doppeltem Anlegen (Doppelklick, Wiederholung nach Zeitueberschreitung): je Inhalt der Rechnung ein Eintrag
|
|
// { at, running }. Er blockiert, solange die Anlage laeuft und DUPLICATE_WINDOW_MS nach einem Erfolg; scheitert die
|
|
// Anlage, ohne dass eine Rechnung bei Stripe bleibt, wird er freigegeben.
|
|
const createGuard = new Map()
|
|
|
|
/** Nur fuer Tests: den Schutz vor doppeltem Anlegen leeren. */
|
|
export function resetInvoiceCreateGuardForTests() {
|
|
createGuard.clear()
|
|
}
|
|
|
|
function claimCreate(fingerprint) {
|
|
const now = Date.now()
|
|
for (const [key, entry] of createGuard) {
|
|
if (!entry.running && now - entry.at >= DUPLICATE_WINDOW_MS) createGuard.delete(key)
|
|
}
|
|
if (createGuard.has(fingerprint)) throw httpError(409, IN_PROGRESS_MESSAGE)
|
|
createGuard.set(fingerprint, { at: now, running: true })
|
|
}
|
|
|
|
function releaseCreate(fingerprint, invoiceKept) {
|
|
if (invoiceKept) createGuard.set(fingerprint, { at: Date.now(), running: false })
|
|
else createGuard.delete(fingerprint)
|
|
}
|
|
|
|
/** Wiederholungsschluessel des Aufrufers: leer = keiner; sonst druckbare Zeichen ohne Leerzeichen, hoechstens 100. */
|
|
function validateIdempotencyKey(key) {
|
|
if (key === undefined || key === null || key === '') return undefined
|
|
if (typeof key !== 'string' || key.length > MAX_IDEMPOTENCY_KEY || !/^[\x21-\x7e]+$/.test(key)) {
|
|
throw httpError(400, `Der Wiederholungsschlüssel ist ungültig (1 bis ${MAX_IDEMPOTENCY_KEY} Zeichen ohne Leerzeichen).`)
|
|
}
|
|
return key
|
|
}
|
|
|
|
/**
|
|
* 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). Mit senden=true und unvollstaendiger Anschrift des Kunden (Strasse, PLZ, Ort)
|
|
* bei einer Summe ueber 250 €: status 409 mit fester Meldung, ebenfalls vor dem ersten Stripe-Aufruf (Entwuerfe ohne
|
|
* Senden sind davon nicht betroffen). Vor der Rechnung wird der Stripe-Kunde (auch ein schon bestehender) auf die
|
|
* aktuellen WOMS-Kundendaten gebracht (E-Mail, Name, Anschrift); scheitert das: status 502 „Kundendaten konnten nicht
|
|
* an Stripe übertragen werden.“ ohne invoiceId, es entsteht keine Rechnung. Scheitert nur das Senden nach dem Festschreiben: status 502 mit invoiceId und
|
|
* invoiceNumber (die offene Rechnung bleibt, erneut senden mit sendInvoiceNow); ebenso, wenn die fertige Rechnung nicht
|
|
* mehr gelesen werden kann (sie besteht dann trotzdem).
|
|
*
|
|
* Doppeltes Anlegen: idempotencyKey (optional, vom Aufrufer je beabsichtigter Rechnung, hoechstens 100 Zeichen) geht als
|
|
* Idempotency-Key an stripe.invoices.create, eine Wiederholung nach einer Zeitueberschreitung liefert dann denselben
|
|
* Entwurf. Nach einem Fehler, der den Entwurf wieder geloescht hat, braucht ein neuer Versuch einen neuen Schluessel.
|
|
* Zusaetzlich nimmt dieser Server dieselbe Rechnung (Kunde, Positionen, Vermerk, Zahlungsziel, senden) nicht an, solange
|
|
* sie angelegt wird und 30 Sekunden danach: status 409.
|
|
*/
|
|
export async function createProjectInvoice({
|
|
customerId,
|
|
art = 'frei',
|
|
projektId,
|
|
auftragId,
|
|
items,
|
|
arbeitsblattIds,
|
|
anzahlung,
|
|
zeilentexte,
|
|
leistungszeitraum,
|
|
daysUntilDue = DEFAULT_DAYS_UNTIL_DUE,
|
|
memo = '',
|
|
senden = false,
|
|
idempotencyKey,
|
|
createdBy,
|
|
}) {
|
|
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 key = validateIdempotencyKey(idempotencyKey)
|
|
|
|
// Der Fingerabdruck wirft nie: ein Array wird sortiert kopiert, jeder andere Wert bleibt; Typfehler meldet baueRechnung (400)
|
|
const sortiert = Array.isArray(arbeitsblattIds) ? [...arbeitsblattIds].sort() : arbeitsblattIds
|
|
const fingerprint = createHash('sha256')
|
|
.update(JSON.stringify([customerId, art, projektId ?? '', auftragId ?? '', items ?? null, sortiert ?? null, anzahlung ?? null, zeilentexte ?? null, leistungszeitraum ?? '', note, daysUntilDue, senden === true]))
|
|
.digest('hex')
|
|
claimCreate(fingerprint)
|
|
try {
|
|
const eingabe = { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, createdBy }
|
|
const anlegen = () => createInvoice({ eingabe, daysUntilDue, note, senden: senden === true, key })
|
|
// Aufwand, Anzahlung und Schluss: Pruefung, Entwurf, Posten, Projektdatensatz und Senden je Kunde nacheinander,
|
|
// damit zwei Admins nie dieselben Arbeitsblaetter oder dieselbe AB gleichzeitig abrechnen
|
|
const invoice = ['aufwand', 'anzahlung', 'schluss'].includes(art) ? await withLocks(sperreRechnung(customerId), anlegen) : await anlegen()
|
|
releaseCreate(fingerprint, true)
|
|
return invoice
|
|
} catch (err) {
|
|
// Eine Rechnung, die bei Stripe bleibt (festgeschrieben, aber nicht gesendet oder nicht lesbar), haelt den Schutz
|
|
releaseCreate(fingerprint, Boolean(err?.invoiceId))
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Vorschau einer Projektrechnung: dieselben Pruefungen wie das Anlegen, aber kein Schreibzugriff (weder Stripe noch
|
|
* Appwrite), kein Schutz vor doppeltem Anlegen, keine Sperre.
|
|
*/
|
|
export async function rechnungsVorschau(eingabe) {
|
|
const { positionen, summeCents, leistungszeitraum, customFields } = await baueRechnung(eingabe)
|
|
return { positionen, summeCents, leistungszeitraum, customFields }
|
|
}
|
|
|
|
async function createInvoice({ eingabe, daysUntilDue, note, senden, key }) {
|
|
const entwurf = await baueRechnung(eingabe)
|
|
const customer = entwurf.kunde
|
|
// Ohne Adresse lehnt Stripe das Senden erst nach dem Festschreiben ab: lieber vorab pruefen
|
|
if (senden && !String(customer.email || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE)
|
|
if (senden) assertAddressForSend(customer, entwurf.summeCents)
|
|
|
|
const { bankDetailsText } = await getSettings()
|
|
const stripe = getStripe()
|
|
const stripeCustomerId = await getOrCreateStripeCustomer(customer)
|
|
// Vor dem ersten Rechnungsaufruf: scheitert das, gibt es keine halbfertige Rechnung
|
|
await syncStripeCustomer(stripe, stripeCustomerId, 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: entwurf.metadata,
|
|
custom_fields: entwurf.customFields.length ? entwurf.customFields : undefined,
|
|
rendering: { pdf: { page_size: 'a4' } },
|
|
},
|
|
key ? { idempotencyKey: key } : undefined
|
|
)
|
|
invoiceId = draft.id
|
|
// der Reihe nach, damit die Positionen in der eingegebenen Reihenfolge auf der Rechnung stehen
|
|
for (const position of entwurf.positionen) {
|
|
const posten = { customer: stripeCustomerId, invoice: invoiceId, description: position.beschreibung }
|
|
// Minuszeile (Abzug fruehere Rechnung): nur amount, ohne quantity
|
|
if (position.einzelpreisCents < 0) Object.assign(posten, { amount: position.betragCents })
|
|
else Object.assign(posten, { quantity: position.menge, unit_amount_decimal: String(position.einzelpreisCents) })
|
|
posten.currency = 'eur'
|
|
await stripe.invoiceItems.create(posten)
|
|
}
|
|
if (entwurf.projektRechnung) await createProjektRechnung(invoiceId, entwurf.projektRechnung)
|
|
} 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 {
|
|
const number = senden ? await finalizeAndSend(stripe, invoiceId, { art: entwurf.art, auftragId: entwurf.auftrag?.id ?? '' }) : ''
|
|
try {
|
|
return await readInvoice(invoiceId)
|
|
} catch (cause) {
|
|
// Die Rechnung besteht (und ist bei senden gesendet): die Seite bekommt ihre Kennung statt eines blanken Fehlers
|
|
const what = senden ? `Rechnung ${number} wurde gesendet` : 'Der Entwurf wurde angelegt'
|
|
throw invoiceExistsError(`${what}, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.`, invoiceId, number, cause)
|
|
}
|
|
} finally {
|
|
invalidateFinanceCache()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Projektrechnung senden: ein Entwurf wird finalisiert (ohne Auto-Advance) und gesendet; eine offene oder ueberfaellige
|
|
* wird nur erneut gesendet (Erinnerung, kein Finalisieren, keine Pruefung der Kundendaten). Bei einem Entwurf mit
|
|
* webklarCustomerId wird der Kunde aus dem WOMS geladen (400 bei unbekanntem oder nicht festem Kunden), die Adress-Sperre
|
|
* geprueft (409, siehe createProjectInvoice) und der Stripe-Kunde vor dem Finalisieren auf die aktuellen Kundendaten
|
|
* gebracht (scheitert das: status 502, nichts wird finalisiert). Ein Entwurf ohne webklarCustomerId (Stripe-Dashboard)
|
|
* wird ohne beides gesendet. 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 raw = await retrieveInvoice(invoiceId)
|
|
const invoice = mapInvoice(raw, { today: berlinDate() })
|
|
// 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.')
|
|
}
|
|
// Nur ein Entwurf wird festgeschrieben: erst dann zaehlen Kundendaten und Anschrift. Eine offene Rechnung steht schon
|
|
// fest (Anschrift und Empfaenger sind festgeschrieben), sie wird nur erneut gesendet.
|
|
let customer = null
|
|
if (isDraft && invoice.webklarCustomerId) customer = await ladeFestenKunden(invoice.webklarCustomerId)
|
|
// Nach dem Abgleich traegt der Stripe-Kunde die E-Mail des WOMS-Kunden; ohne die gilt die, die Stripe schon hat
|
|
const email = String(customer?.email || '').trim() || String(invoice.customerEmail || '').trim()
|
|
if (!email) throw httpError(400, NO_EMAIL_MESSAGE)
|
|
if (customer) assertAddressForSend(customer, invoice.totalCents)
|
|
try {
|
|
if (isDraft) {
|
|
// Der Entwurf kann Wochen alt sein: den Stripe-Kunden vor dem Festschreiben auf die aktuellen Kundendaten bringen
|
|
// (Name, E-Mail, Anschrift stehen danach unveraenderlich auf der Rechnung). Ohne Kennung (z. B. Entwurf aus dem
|
|
// Stripe-Dashboard) gibt es keine WOMS-Daten zum Abgleichen.
|
|
if (customer) await syncStripeCustomer(getStripe(), stripeCustomerIdOf(raw), customer)
|
|
await finalizeAndSend(getStripe(), invoice.id, { art: invoice.art, auftragId: invoice.auftragId })
|
|
} else {
|
|
// Selbstheilung nach einem ausgefallenen Buchen: eine fehlende Rechnungsnummer im Datensatz nachtragen
|
|
await bucheNummer(invoice.id, invoice.number, invoice.art)
|
|
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)
|
|
await bucheStorniert(invoice.id, invoice.art)
|
|
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)
|
|
await bucheStorniert(invoice.id, invoice.art)
|
|
} finally {
|
|
invalidateFinanceCache()
|
|
}
|
|
}
|