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:
1 parent
73811e0300
commit
f0b57ebe41
5 files changed
+377
-26
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user