From 73811e030096913cf3c6378dfdf9f38c755e5ffa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:18:25 +0000 Subject: [PATCH] Finanzen: Stripe-Kunde vor der Rechnung auf die aktuellen Kundendaten bringen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein wiederverwendeter Stripe-Kunde (ueber das Abo oder die Metadaten gefunden) trug noch die alte E-Mail und Anschrift, die Stripe auf die Rechnung schreibt (Empfaengeranschrift, § 14 Abs. 4 Nr. 1 UStG). createProjectInvoice aktualisiert ihn jetzt nach getOrCreateStripeCustomer und vor dem ersten Rechnungsaufruf mit E-Mail, Name (Firma vor Name) und Anschrift (Land DE, wenn keine zweistellige Angabe). Scheitert das, entsteht keine Rechnung: 502 "Kundendaten konnten nicht an Stripe uebertragen werden." ohne invoiceId. Die Felder baut stripeCustomerFields fuer das Anlegen und das Aktualisieren gemeinsam. Der Stripe-Nachbau kennt POST /v1/customers/:id. Co-Authored-By: Claude Sonnet 5.5 --- server/services/finance/stripeFinance.js | 28 ++++++++- server/services/stripeBilling.js | 33 +++++++---- test/financeStripeWrite.test.js | 74 ++++++++++++++++++++++++ test/support/fakeStripe.js | 19 ++++++ 4 files changed, 142 insertions(+), 12 deletions(-) diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index afa1334..e408e16 100644 --- a/server/services/finance/stripeFinance.js +++ b/server/services/finance/stripeFinance.js @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { config } from '../../config.js' -import { getStripe, getOrCreateStripeCustomer, subscriptionIdOfInvoice } from '../stripeBilling.js' +import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptionIdOfInvoice } from '../stripeBilling.js' import { getDocument } from '../appwriteAdmin.js' import { addDays, berlinDate, daysBetween } from './dates.js' import { getSettings } from './settings.js' @@ -156,6 +156,26 @@ export class InvoiceError extends Error { 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 + } +} + /** Rechnungsfuss: Kleinunternehmer-Hinweis, Bankdaten (falls gesetzt), Hinweis auf den Verwendungszweck. */ export function invoiceFooter(bankDetailsText = '') { const bank = typeof bankDetailsText === 'string' ? bankDetailsText.trim() : '' @@ -278,7 +298,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). Scheitert nur das Senden nach dem Festschreiben: status 502 mit invoiceId und + * Fehler beim Lesen der Einstellungen). 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). * @@ -326,6 +348,8 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke 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( diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index 91c1d0b..c9da269 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -431,6 +431,28 @@ export async function grantCreditForInvoice(invoice, { fresh = false } = {}) { // Checkout / Kundenportal / Kuendigung // --------------------------------------------------------------------------- +/** + * 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. + */ +export function stripeCustomerFields(customer) { + const t = (v) => (typeof v === 'string' ? v.trim() : '') + const country = t(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', + } + : undefined, + } +} + export async function getOrCreateStripeCustomer(customer) { const stripe = getStripe() const subs = await listSubscriptionsForCustomer(customer.$id) @@ -443,17 +465,8 @@ export async function getOrCreateStripeCustomer(customer) { if (found.data[0]) return found.data[0].id const created = await stripe.customers.create({ - email: customer.email || undefined, - name: customer.companyName || customer.name || undefined, + ...stripeCustomerFields(customer), preferred_locales: ['de'], - address: customer.street - ? { - line1: customer.street, - postal_code: customer.postalCode || undefined, - city: customer.city || undefined, - country: customer.country && customer.country.length === 2 ? customer.country : 'DE', - } - : undefined, invoice_settings: { footer: config.stripe.invoiceFooter }, metadata: { webklarCustomerId: customer.$id, kundennummer: customer.code || '' }, }) diff --git a/test/financeStripeWrite.test.js b/test/financeStripeWrite.test.js index a5e0065..6a18959 100644 --- a/test/financeStripeWrite.test.js +++ b/test/financeStripeWrite.test.js @@ -25,6 +25,10 @@ aw.seed('customers', { $id: 'lead1', email: 'lead@example.invalid', name: 'Inter aw.seed('customers', { $id: 'lost1', email: 'lost@example.invalid', name: 'Verloren', customerStatus: 'Lost', status: 'active' }) aw.seed('customers', { $id: 'act1', email: 'act@example.invalid', name: 'Aktiv', companyName: 'Aktiv GmbH', customerStatus: 'active', status: 'active' }) +aw.seed('customers', { + $id: 'addr1', email: 'neu@example.invalid', name: 'Anna Adresse', companyName: 'Adresse GmbH', customerStatus: 'active', status: 'active', + street: ' Hauptstr. 1 ', postalCode: '10115', city: 'Berlin', country: 'Deutschland', +}) aw.seed('customers', { $id: 'noMail', name: 'Ohne Mail', customerStatus: 'active', status: 'active' }) aw.seed('customers', { $id: 'blankMail', email: ' ', name: 'Leere Mail', customerStatus: 'active', status: 'active' }) @@ -187,6 +191,76 @@ test('createProjectInvoice: legt fuer Kunden ohne Stripe-Kunde einen an (Firmenn assert.equal(result.customerName, 'Aktiv GmbH') }) +test('createProjectInvoice: ein bestehender Stripe-Kunde wird vor der Rechnung auf die aktuellen WOMS-Daten gebracht (E-Mail, Name, Anschrift)', async () => { + // Stripe-Kunde aus frueherer Zeit (ueber die Metadaten gefunden): alte E-Mail, alter Name, alte Anschrift + st.customers.set('cus_addr1', { + id: 'cus_addr1', object: 'customer', name: 'Anna Alt', email: 'alt@example.invalid', + address: { line1: 'Alte Str. 9', postal_code: '80331', city: 'München', country: 'DE' }, metadata: { webklarCustomerId: 'addr1' }, + }) + const result = await createProjectInvoice({ customerId: 'addr1', items: [BASE_ITEMS[1]], createdBy: 'k' }) + + assert.equal(callsTo('POST', '/v1/customers').length, 0, 'kein zweiter Stripe-Kunde') + const updates = callsTo('POST', '/v1/customers/cus_addr1') + assert.equal(updates.length, 1) + assert.deepEqual(updates[0].body, { + email: 'neu@example.invalid', + name: 'Adresse GmbH', + 'address[line1]': 'Hauptstr. 1', + 'address[postal_code]': '10115', + 'address[city]': 'Berlin', + 'address[country]': 'DE', + }) + // der Stripe-Kunde traegt danach die aktuellen Daten, die Rechnung gehoert ihm + const stored = st.customers.get('cus_addr1') + assert.equal(stored.email, 'neu@example.invalid') + assert.equal(stored.name, 'Adresse GmbH') + assert.deepEqual(stored.address, { line1: 'Hauptstr. 1', postal_code: '10115', city: 'Berlin', country: 'DE' }) + assert.equal(stored.metadata.webklarCustomerId, 'addr1') + assert.equal(st.invoices.get(result.id).customer, 'cus_addr1') + // zuerst der Kunde, dann die Rechnung + const order = writes().map((c) => c.path.replace(/in_test_\d+/, ':id')) + assert.deepEqual(order.slice(0, 2), ['/v1/customers/cus_addr1', '/v1/invoices']) +}) + +test('createProjectInvoice: Stripe-Kunde aus dem Abo (ohne Anschrift im WOMS) bekommt E-Mail und Name, keine Anschrift; Land AT bleibt AT', async () => { + await createProjectInvoice({ customerId: 'c1', items: [BASE_ITEMS[1]], createdBy: 'k' }) + const [update] = callsTo('POST', '/v1/customers/cus_c1') + assert.deepEqual(update.body, { email: 'k@example.invalid', name: 'Kunde Eins' }) + assert.equal(st.customers.get('cus_c1').address ?? null, null) + + aw.seed('customers', { $id: 'at1', email: 'at@example.invalid', name: 'Österreicher', customerStatus: 'active', status: 'active', street: 'Ring 1', postalCode: '1010', city: 'Wien', country: 'AT' }) + resetInvoiceCreateGuardForTests() + await createProjectInvoice({ customerId: 'at1', items: [BASE_ITEMS[1]], createdBy: 'k' }) + const created = callsTo('POST', '/v1/customers').find((c) => c.body.email === 'at@example.invalid') + assert.equal(created.body['address[country]'], 'AT', 'Anlegen und Aktualisieren benutzen dieselben Felder') + const updated = callsTo('POST', /^\/v1\/customers\/cus_test_/).at(-1) + assert.equal(updated.body['address[country]'], 'AT') + assert.equal(updated.body['address[line1]'], 'Ring 1') +}) + +test('createProjectInvoice: scheitert die Uebertragung der Kundendaten, gibt es keine Rechnung (502) und ein neuer Versuch geht', async () => { + st.customers.set('cus_addr1', { id: 'cus_addr1', object: 'customer', name: 'Anna Alt', email: 'alt@example.invalid', address: null, metadata: { webklarCustomerId: 'addr1' } }) + st.failNext({ method: 'POST', path: '/v1/customers/cus_addr1', status: 400, message: 'geheime Stripe-Meldung' }) + await assert.rejects( + createProjectInvoice({ customerId: 'addr1', items: [BASE_ITEMS[1]], senden: true, createdBy: 'k' }), + (err) => { + assert.equal(err.name, 'InvoiceError') + assert.equal(err.status, 502) + assert.equal(err.message, 'Kundendaten konnten nicht an Stripe übertragen werden.') + assert.equal(err.invoiceId, undefined, 'keine Rechnung entstanden') + assert.ok(err.cause, 'Ursache bleibt fuers Protokoll') + return true + } + ) + // nichts an Rechnungen: weder Entwurf noch Posten noch Senden + assert.equal(callsTo('POST', /^\/v1\/invoice/).length, 0) + assert.equal(st.invoices.size, 0) + // der Schutz vor doppeltem Anlegen ist freigegeben: derselbe Aufruf klappt, sobald Stripe wieder antwortet + const result = await createProjectInvoice({ customerId: 'addr1', items: [BASE_ITEMS[1]], senden: true, createdBy: 'k' }) + assert.equal(result.status, 'offen') + assert.equal(st.customers.get('cus_addr1').email, 'neu@example.invalid') +}) + test('createProjectInvoice: senden=true finalisiert (ohne Auto-Advance) und sendet, Rechnung ist offen mit Nummer', async () => { const result = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, daysUntilDue: 30, senden: true, createdBy: 'k' }) diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index 2acc8bf..1ea1b7f 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -27,6 +27,8 @@ import http from 'node:http' * DELETE /v1/invoices/:id (nur draft); jeder andere Zustand ist wie bei Stripe ein 400. Der Kunde einer neuen * Rechnung muss nicht in st.customers stehen (dann bleiben Name und E-Mail leer). Traegt ein Aufruf die Kopfzeile * Idempotency-Key, steht sie als idempotencyKey in calls; delayNext verzoegert eine Antwort (Zeitgrenzen testen). + * Kunden: GET /v1/customers/search (Metadaten), POST /v1/customers (anlegen) und POST /v1/customers/:id (aendern: + * email, name, address, metadata; unbekannte ID -> 404). */ // Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/ @@ -518,6 +520,23 @@ export async function startFakeStripe() { customers.set(obj.id, obj) return [200, obj] } + // Kunde aendern (stripe.customers.update): email, name, address (ersetzt) und metadata (zusammengefuehrt) werden uebernommen + if (method === 'POST' && (m = path.match(/^\/v1\/customers\/([^/]+)$/))) { + const customer = customers.get(m[1]) + if (!customer) return noSuch('customer', m[1]) + const data = unflatten(body) + const metadata = data.metadata ? mergeMetadata(customer.metadata, data.metadata) : customer.metadata + if (Array.isArray(metadata)) return metadata + const updated = { + ...customer, + ...(data.email !== undefined ? { email: data.email || null } : {}), + ...(data.name !== undefined ? { name: data.name || null } : {}), + ...(data.address !== undefined ? { address: data.address } : {}), + metadata, + } + customers.set(customer.id, updated) + return [200, updated] + } if (method === 'POST' && path === '/v1/checkout/sessions') { const data = unflatten(body) const id = nextId('cs')