Rechnungen: Projektrechnungen bei Stripe (Zusatzfelder, A4, Minuszeilen, Projektdatensatz) und Vorschau

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:16:48 +00:00
1 parent c116c3cb4f
commit d264f2af5a
3 files changed
+151 -63

No files matched your search

+2 -1
View File
@@ -1,6 +1,7 @@
import { Router } from 'express'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { withLocks } from '../../services/keyedLock.js'
import { AuftragError } from '../../services/auftraege/fehler.js'
import { EnableBankingError } from '../../services/finance/enableBanking.js'
import { completeBankAuth, startBankAuth, syncBank } from '../../services/finance/bankSync.js'
import { getConnection, listTransactions, publicConnection, saveConnection } from '../../services/finance/bankStore.js'
@@ -148,7 +149,7 @@ const isStripeError = (err) => typeof err?.type === 'string' && err.type.startsW
* Fehler einen status tragen: nur die beiden eigenen Fehlerarten werden weitergegeben.
*/
function sendFinanceError(res, what, err) {
if (err instanceof AssignError || err instanceof InvoiceError) {
if (err instanceof AssignError || err instanceof InvoiceError || err instanceof AuftragError) {
// Ein 502 an einer bestehenden Rechnung (nicht gesendet, nicht lesbar) hat seine Ursache bei Stripe: nur Art, Status und Code ins Protokoll
if (err.status === 502 && err.cause) logFailure(what, err.cause)
const body = { error: err.message }
+41 -62
View File
@@ -1,9 +1,10 @@
import { createHash } from 'node:crypto'
import { config } from '../../config.js'
import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptionIdOfInvoice } from '../stripeBilling.js'
import { getDocument } from '../appwriteAdmin.js'
import { addDays, berlinDate, daysBetween } from './dates.js'
import { RECHNUNGS_ARTEN } from '../auftraege/konstanten.js'
import { baueRechnung, ladeFestenKunden } from '../auftraege/rechnungsbau.js'
import { createProjektRechnung } from '../auftraege/projektRechnungenStore.js'
import { getSettings } from './settings.js'
/**
@@ -132,11 +133,6 @@ export async function listPayouts({ force = false } = {}) {
// 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
@@ -208,45 +204,6 @@ export function invoiceFooter(bankDetailsText = '') {
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), roh; unbekannte Rechnung -> status 404. */
async function retrieveInvoice(invoiceId) {
if (typeof invoiceId !== 'string' || !/^in_\w+$/.test(invoiceId)) throw httpError(404, 'Rechnung nicht gefunden.')
@@ -344,14 +301,20 @@ function validateIdempotencyKey(key) {
*/
export async function createProjectInvoice({
customerId,
art = 'frei',
projektId,
auftragId,
items,
arbeitsblattIds,
anzahlung,
zeilentexte,
leistungszeitraum,
daysUntilDue = DEFAULT_DAYS_UNTIL_DUE,
memo = '',
senden = false,
idempotencyKey,
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.`)
}
@@ -359,10 +322,15 @@ export async function createProjectInvoice({
if (note.length > MAX_MEMO) throw httpError(400, `Der Vermerk darf höchstens ${MAX_MEMO} Zeichen lang sein.`)
const key = validateIdempotencyKey(idempotencyKey)
const fingerprint = createHash('sha256').update(JSON.stringify([customerId, lines, note, daysUntilDue, senden === true])).digest('hex')
// 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 invoice = await createInvoice({ customerId, lines, daysUntilDue, note, senden: senden === true, key, createdBy })
const eingabe = { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, createdBy }
const invoice = await createInvoice({ eingabe, daysUntilDue, note, senden: senden === true, key })
releaseCreate(fingerprint, true)
return invoice
} catch (err) {
@@ -372,11 +340,21 @@ export async function createProjectInvoice({
}
}
async function createInvoice({ customerId, lines, daysUntilDue, note, senden, key, createdBy }) {
const customer = await loadBillableCustomer(customerId)
/**
* 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, lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0))
if (senden) assertAddressForSend(customer, entwurf.summeCents)
const { bankDetailsText } = await getSettings()
const stripe = getStripe()
@@ -395,22 +373,23 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke
currency: 'eur',
footer: invoiceFooter(bankDetailsText),
description: note || undefined,
metadata: { webklarCustomerId: customer.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500) },
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 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',
})
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(() => {})
@@ -454,7 +433,7 @@ export async function sendInvoiceNow(invoiceId) {
// 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 loadBillableCustomer(invoice.webklarCustomerId)
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)
+108
View File
@@ -55,3 +55,111 @@ test('mapInvoice: Projektfelder aus den Metadaten, art mit Rückfall abo oder fr
assert.deepEqual(felder(mapInvoice(roh({ metadata }), heute)), ['p1', 'Website Wolf', 'schluss', 'a1', 'AB-2026-0002'])
for (const art of ['quatsch', 'abo', '']) assert.equal(mapInvoice(roh({ metadata: { art } }), heute).art, 'frei', `art "${art}"`)
})
test('createProjectInvoice frei: A4 für jede Rechnung, Art in den Metadaten, keine Zusatzfelder, kein Projektdatensatz', async () => {
const rechnung = await createProjectInvoice({ customerId: 'k1', items: POSTEN, createdBy: 'kenso@webklar.com' })
const [create] = callsTo('POST', '/v1/invoices')
assert.equal(create.body['rendering[pdf][page_size]'], 'a4')
assert.equal(create.body['metadata[art]'], 'frei')
assert.equal(create.body['metadata[webklarCustomerId]'], 'k1')
assert.ok(!Object.keys(create.body).some((k) => k.startsWith('custom_fields')))
assert.ok(!Object.keys(create.body).some((k) => /^metadata\[(projektId|projektName|auftragId|auftragNummer)\]$/.test(k)))
assert.deepEqual([rechnung.art, rechnung.status, rechnung.totalCents, rechnung.projektId], ['frei', 'entwurf', 1200, ''])
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
})
test('createProjectInvoice festbetrag: Zusatzfeld Projekt, Metadaten und ein Projektdatensatz mit der Rechnungs-ID', async () => {
const items = [{ description: 'Erstellung der Website anelia-wolf.com, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }]
const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items, createdBy: 'kenso@webklar.com' })
const [create] = callsTo('POST', '/v1/invoices')
assert.equal(create.body['custom_fields[0][name]'], 'Projekt')
assert.equal(create.body['custom_fields[0][value]'], 'Website anelia-wolf.com')
assert.equal(create.body['custom_fields[1][name]'], undefined)
assert.equal(create.body['rendering[pdf][page_size]'], 'a4')
assert.deepEqual([create.body['metadata[art]'], create.body['metadata[projektId]'], create.body['metadata[projektName]']], ['festbetrag', 'p1', 'Website anelia-wolf.com'])
assert.deepEqual([rechnung.art, rechnung.projektId, rechnung.projektName, rechnung.totalCents], ['festbetrag', 'p1', 'Website anelia-wolf.com', 50000])
const doc = await getProjektRechnung(rechnung.id)
assert.deepEqual(
[doc.invoiceId, doc.kundenId, doc.projektId, doc.auftragId, doc.art, doc.betragCents, doc.rechnungsnummer, doc.aktiv, doc.erstelltVon],
[rechnung.id, 'k1', 'p1', '', 'festbetrag', 50000, '', true, 'kenso@webklar.com']
)
})
test('createProjectInvoice aufwand: eine Zeile je Tag bei Stripe, Blätter im Projektdatensatz, Zeitraum als Zusatzfeld', async () => {
const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2', 'ws3'], createdBy: 'k' })
const items = callsTo('POST', '/v1/invoiceitems')
assert.deepEqual(items.map((c) => [c.body.quantity, c.body.unit_amount_decimal, c.body.amount]), [['1', '22500', undefined], ['1', '10000', undefined]])
assert.ok(items[0].body.description.startsWith('06.10.2026: '))
const [create] = callsTo('POST', '/v1/invoices')
assert.deepEqual([0, 1].map((i) => create.body[`custom_fields[${i}][name]`]), ['Projekt', 'Leistungszeitraum'])
assert.equal(create.body['custom_fields[1][value]'], '06.10.–07.10.2026')
assert.equal(rechnung.totalCents, 32500)
assert.deepEqual((await getProjektRechnung(rechnung.id)).arbeitsblattIds, ['ws1', 'ws2', 'ws3'])
})
test('createProjectInvoice schluss: positive Zeilen mit Menge und Einzelpreis, die Minuszeile nur mit amount (ohne quantity)', async () => {
const ab = await legeAuftragAn()
const anzahlung = await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true, createdBy: 'k' })
assert.deepEqual([anzahlung.totalCents, anzahlung.art, anzahlung.auftragNummer, anzahlung.status], [40000, 'anzahlung', 'AB-2026-0002', 'offen'])
st.calls.length = 0
const schluss = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, createdBy: 'k' })
const [create] = callsTo('POST', '/v1/invoices')
assert.deepEqual(
[0, 1].map((i) => [create.body[`custom_fields[${i}][name]`], create.body[`custom_fields[${i}][value]`]]),
[['Projekt', 'Website anelia-wolf.com'], ['Auftrag', 'AB-2026-0002 vom 06.10.2026']]
)
const items = callsTo('POST', '/v1/invoiceitems')
const kunde = items[0].body.customer
assert.deepEqual(items.map((c) => c.body), [
{ customer: kunde, invoice: schluss.id, description: 'Website', quantity: '1', unit_amount_decimal: '60000', currency: 'eur' },
{ customer: kunde, invoice: schluss.id, description: 'Texte', quantity: '2', unit_amount_decimal: '10000', currency: 'eur' },
{ customer: kunde, invoice: schluss.id, description: `abzüglich Anzahlung, Rechnung ${anzahlung.number} vom ${formatDe(berlinDate())}`, amount: '-40000', currency: 'eur' },
])
assert.deepEqual([schluss.totalCents, schluss.art, schluss.status], [40000, 'schluss', 'entwurf'])
})
test('createProjectInvoice: scheitert der Projektdatensatz, wird der Entwurf bei Stripe gelöscht und nichts bleibt zurück', async () => {
const eingabe = { customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, createdBy: 'k' }
aw.failNext({ collection: config.collections.projektRechnungen, method: 'POST', status: 500 })
await assert.rejects(createProjectInvoice(eingabe))
assert.equal(st.invoices.size, 0)
assert.equal(callsTo('DELETE', /^\/v1\/invoices\/in_/).length, 1)
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
// der Schutz vor doppeltem Anlegen ist wieder frei
assert.equal((await createProjectInvoice(eingabe)).status, 'entwurf')
})
test('createProjectInvoice: der Fingerabdruck unterscheidet Art, Projekt, Auftrag und Anzahlung, gleicher Inhalt bleibt 30 Sekunden gesperrt', async () => {
const basis = { customerId: 'k1', items: POSTEN }
const GESPERRT = 'Diese Rechnung wird gerade schon erstellt.'
await createProjectInvoice(basis)
await createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p1' })
await createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p3' })
await wirft(createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p3' }), 409, GESPERRT)
const ab = await legeAuftragAn()
await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 50 } })
await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 } })
await wirft(createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 } }), 409, GESPERRT)
assert.equal(st.invoices.size, 5)
})
test('rechnungsVorschau: Positionen, Summe, Zeitraum und Zusatzfelder ohne Stripe-Schreibzugriff und ohne Projektdatensatz', async () => {
const v = await rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1' })
assert.deepEqual(Object.keys(v).sort(), ['customFields', 'leistungszeitraum', 'positionen', 'summeCents'])
assert.equal(v.summeCents, 32500)
assert.deepEqual(v.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07'])
assert.equal(v.leistungszeitraum, '06.10.–07.10.2026')
assert.deepEqual(v.customFields.map((f) => f.name), ['Projekt', 'Leistungszeitraum'])
assert.equal(writes().length, 0)
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
})
test('rechnungsVorschau: dieselben Prüfungen wie das Anlegen; Anzahlung ohne Angaben zeigt den Standardwert', async () => {
await createProjectInvoice({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] })
await wirft(rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws3'] }), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
await wirft(rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p2' }), 404, 'Projekt nicht gefunden.')
const ab = await legeAuftragAn()
const v = await rechnungsVorschau({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id })
assert.deepEqual([v.summeCents, v.positionen[0].schluessel], [40000, 'anzahlung'])
assert.equal(aw.docs(config.collections.projektRechnungen).size, 1, 'nur der Entwurf von oben')
})