Finanzen: Nachtrag vor Livegang (Kundenabgleich beim Senden, Adress-Sperre, Land und Anschrift sauber an Stripe)

- sendInvoiceNow: Entwurf mit webklarCustomerId laedt den WOMS-Kunden und bringt den Stripe-Kunden vor dem
  Finalisieren auf die aktuellen Daten (Fehler: 502, nichts finalisiert); ohne Kennung kein Abgleich
- Adress-Sperre (§ 14 Abs. 4 Nr. 1 UStG): Senden ueber 250 € ohne Strasse/PLZ/Ort -> 409 vor jedem Stripe-Schreibzugriff
  (Anlegen mit senden=true und Entwurf senden; Text und Grenze wie im WOMS)
- stripeCustomerFields: Land aus deutschen Namen (unbekannt -> weglassen), PLZ/Ort mit Strasse immer mitsenden ('' loescht)
- Stripe-Nachbau: Kunden-Update fuehrt die Anschrift feldweise zusammen, '' loescht ein Feld

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:42:06 +00:00
1 parent 73811e0300
commit f0b57ebe41
5 files changed
+377 -26

No files matched your search

+57 -14
View File
@@ -176,6 +176,24 @@ async function syncStripeCustomer(stripe, stripeCustomerId, customer) {
}
}
// 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() : ''
@@ -223,17 +241,20 @@ async function loadBillableCustomer(customerId) {
return customer
}
/** Eine Rechnung frisch von Stripe (mit Kunde), gemappt; unbekannte Rechnung -> status 404. */
async function readInvoice(invoiceId) {
/** 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.')
let raw
try {
raw = await getStripe().invoices.retrieve(invoiceId, { expand: ['customer'] })
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
}
return mapInvoice(raw, { today: berlinDate() })
}
/** Eine Rechnung frisch von Stripe (mit Kunde), gemappt; unbekannte Rechnung -> status 404. */
async function readInvoice(invoiceId) {
return mapInvoice(await retrieveInvoice(invoiceId), { today: berlinDate() })
}
// Finalisieren ohne Auto-Advance (die Rechnung bleibt in unserer Hand), dann per E-Mail senden. Scheitert nur das
@@ -250,6 +271,9 @@ async function finalizeAndSend(stripe, invoiceId) {
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)
@@ -298,7 +322,9 @@ function validateIdempotencyKey(key) {
* 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). Vor der Rechnung wird der Stripe-Kunde (auch ein schon bestehender) auf die
* 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
@@ -344,6 +370,7 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke
const customer = await loadBillableCustomer(customerId)
// 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))
const { bankDetailsText } = await getSettings()
const stripe = getStripe()
@@ -401,23 +428,39 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke
/**
* 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).
* 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 invoice = await readInvoice(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.')
}
if (!String(invoice.customerEmail || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE)
// 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)
// 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) await finalizeAndSend(getStripe(), invoice.id)
else await getStripe().invoices.sendInvoice(invoice.id)
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)
} else await getStripe().invoices.sendInvoice(invoice.id)
return await readInvoice(invoice.id)
} finally {
invalidateFinanceCache()
+36 -6
View File
@@ -431,23 +431,53 @@ export async function grantCreditForInvoice(invoice, { fresh = false } = {}) {
// Checkout / Kundenportal / Kuendigung
// ---------------------------------------------------------------------------
// Deutsche Laendernamen (klein geschrieben) -> ISO-Code fuer Stripe; die Liste deckt die ueblichen Faelle ab
const COUNTRY_CODES = {
deutschland: 'DE',
österreich: 'AT',
oesterreich: 'AT',
schweiz: 'CH',
niederlande: 'NL',
belgien: 'BE',
frankreich: 'FR',
italien: 'IT',
spanien: 'ES',
polen: 'PL',
dänemark: 'DK',
luxemburg: 'LU',
liechtenstein: 'LI',
}
/**
* ISO-Code fuer das Land eines WOMS-Kunden: zweistelliger Code -> gross; deutscher Name (ohne Gross-/Kleinschreibung) ->
* Code; leer -> DE; alles andere -> null (das Land bleibt dann bei Stripe unberuehrt, statt falsch auf DE zu springen).
*/
function stripeCountry(value) {
const raw = typeof value === 'string' ? value.normalize('NFC').trim() : ''
if (!raw) return 'DE'
if (/^[A-Za-z]{2}$/.test(raw)) return raw.toUpperCase()
return COUNTRY_CODES[raw.toLowerCase()] || null
}
/**
* Stripe-Kundendaten aus dem WOMS-Kunden (Appwrite-Dokument): E-Mail, Name (Firmenname vor Name) und, nur wenn eine
* Strasse eingetragen ist, die Anschrift (Land: zweistelliger Code, sonst DE). Leere Werte fehlen (undefined), damit
* Stripe sie nicht anfasst. Gemeinsam fuer das Anlegen und das Aktualisieren des Stripe-Kunden.
* Strasse eingetragen ist, die Anschrift. Mit Strasse gehen PLZ und Ort immer mit (fehlende als '': Stripe loescht dann
* einen alten Wert, statt ihn neben der neuen Strasse stehen zu lassen). Land: siehe stripeCountry (unbekannt -> fehlt).
* Leere Werte fehlen (undefined), damit Stripe sie nicht anfasst. Gemeinsam fuer das Anlegen und das Aktualisieren des
* Stripe-Kunden.
*/
export function stripeCustomerFields(customer) {
const t = (v) => (typeof v === 'string' ? v.trim() : '')
const country = t(customer.country)
const country = stripeCountry(customer.country)
return {
email: t(customer.email) || undefined,
name: t(customer.companyName) || t(customer.name) || undefined,
address: t(customer.street)
? {
line1: t(customer.street),
postal_code: t(customer.postalCode) || undefined,
city: t(customer.city) || undefined,
country: country.length === 2 ? country.toUpperCase() : 'DE',
postal_code: t(customer.postalCode),
city: t(customer.city),
...(country ? { country } : {}),
}
: undefined,
}