diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index e408e16..0106e24 100644 --- a/server/services/finance/stripeFinance.js +++ b/server/services/finance/stripeFinance.js @@ -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() diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index c9da269..aa752bf 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -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, } diff --git a/test/financeOverview.test.js b/test/financeOverview.test.js index 7d8cea7..50658d3 100644 --- a/test/financeOverview.test.js +++ b/test/financeOverview.test.js @@ -987,13 +987,60 @@ test('POST /rechnungen/:id/senden: bezahlt -> 409, Abo-Rechnung -> 409, unbekann }) test('POST /rechnungen/:id/senden: Kunde ohne E-Mail-Adresse -> 400', async () => { - putInvoice({ id: 'in_nomail', status: 'draft', number: null, customer_email: null, customer: 'cus_ohne' }) + // Entwurf ohne webklarCustomerId (z. B. aus dem Stripe-Dashboard): es gibt keine WOMS-E-Mail, die Stripe nachgetragen werden koennte + putInvoice({ id: 'in_nomail', status: 'draft', number: null, customer_email: null, customer: 'cus_ohne', metadata: {} }) st.customers.set('cus_ohne', { id: 'cus_ohne', object: 'customer', name: 'Ohne', email: null, metadata: {} }) const res = await senden('in_nomail') assert.equal(res.status, 400) assert.equal(res.body.error, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.') }) +const ADDRESS_INCOMPLETE = 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.' +const BIG_ITEMS = [{ description: 'Großauftrag', quantity: 1, unitAmountCents: 30_000 }] + +test('POST /rechnungen: senden bei unvollstaendiger Anschrift und Summe ueber 250 € -> 409 mit fester Meldung, nichts bei Stripe; Entwurf geht', async () => { + // c1 hat in den Testdaten keine Anschrift + const blocked = await postInvoice({ customerId: 'c1', items: BIG_ITEMS, senden: true }) + assert.equal(blocked.status, 409) + assert.deepEqual(blocked.body, { error: ADDRESS_INCOMPLETE }) + assert.equal(st.calls.length, 0) + const draft = await postInvoice({ customerId: 'c1', items: BIG_ITEMS }) + assert.equal(draft.status, 200, JSON.stringify(draft.body)) + assert.equal(draft.body.rechnung.status, 'entwurf') +}) + +test('POST /rechnungen/:id/senden: Entwurf ueber 250 € ohne Anschrift -> 409; mit ergaenzter Anschrift wird der Stripe-Kunde vor dem Finalisieren abgeglichen', async () => { + const draft = (await postInvoice({ customerId: 'c1', items: BIG_ITEMS })).body.rechnung.id + st.calls.length = 0 + const blocked = await senden(draft) + assert.equal(blocked.status, 409) + assert.deepEqual(blocked.body, { error: ADDRESS_INCOMPLETE }) + assert.equal(st.calls.filter((c) => c.method !== 'GET').length, 0) + + aw.seed('customers', { ...customers.c1, street: 'Hauptstr. 1', postalCode: '10115', city: 'Berlin' }) + st.calls.length = 0 + const sent = await senden(draft) + assert.equal(sent.status, 200, JSON.stringify(sent.body)) + assert.equal(sent.body.rechnung.status, 'offen') + assert.deepEqual( + st.calls.filter((c) => c.method !== 'GET').map((c) => c.path.replace(draft, ':id')), + ['/v1/customers/cus_c1', '/v1/invoices/:id/finalize', '/v1/invoices/:id/send'] + ) + assert.equal(st.customers.get('cus_c1').address.line1, 'Hauptstr. 1') + aw.seed('customers', customers.c1) +}) + +test('POST /rechnungen/:id/senden: scheitert der Abgleich des Stripe-Kunden -> 502 mit fester Meldung, nicht finalisiert', async () => { + const draft = (await postInvoice({ customerId: 'c1', items: ITEMS })).body.rechnung.id + st.calls.length = 0 + st.failNext({ method: 'POST', path: '/v1/customers/cus_c1', status: 400, message: 'geheime Stripe-Meldung' }) + const res = await senden(draft) + assert.equal(res.status, 502) + assert.deepEqual(res.body, { error: 'Kundendaten konnten nicht an Stripe übertragen werden.' }) + assert.equal(callsTo('POST', /\/(finalize|send)$/).length, 0) + assert.equal(st.invoices.get(draft).status, 'draft') +}) + test('POST /rechnungen/:id/stornieren: offene Rechnung -> storniert; Entwurf und bezahlte -> 409', async () => { putInvoice({ id: 'in_open' }) putInvoice({ id: 'in_draft', status: 'draft', number: null }) diff --git a/test/financeStripeWrite.test.js b/test/financeStripeWrite.test.js index 6a18959..865acad 100644 --- a/test/financeStripeWrite.test.js +++ b/test/financeStripeWrite.test.js @@ -12,7 +12,7 @@ useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { seedFixtures } = await import('./support/fixtures.js') const { berlinDate } = await import('../server/services/finance/dates.js') -const { getStripe } = await import('../server/services/stripeBilling.js') +const { getStripe, stripeCustomerFields } = await import('../server/services/stripeBilling.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { invoiceFooter, createProjectInvoice, sendInvoiceNow, voidInvoice, deleteDraft, listInvoices, invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import( '../server/services/finance/stripeFinance.js' @@ -29,10 +29,16 @@ 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: 'noAddr', email: 'ohne@example.invalid', name: 'Ohne Adresse', customerStatus: 'active', status: 'active' }) +aw.seed('customers', { $id: 'noPlz', email: 'plz@example.invalid', name: 'Ohne PLZ', customerStatus: 'active', status: 'active', street: 'Hauptstr. 2', postalCode: ' ', city: 'Berlin' }) +aw.seed('customers', { $id: 'noCity', email: 'ort@example.invalid', name: 'Ohne Ort', customerStatus: 'active', status: 'active', street: 'Hauptstr. 3', postalCode: '10115', city: '' }) +aw.seed('customers', { $id: 'noStreet', email: 'str@example.invalid', name: 'Ohne Strasse', customerStatus: 'active', status: 'active', street: ' ', postalCode: '10115', city: 'Berlin' }) 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' }) const NO_MAIL = 'Für den Versand fehlt die E-Mail-Adresse des Kunden.' +const ADDRESS_INCOMPLETE = 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.' +const SYNC_FAILED = 'Kundendaten konnten nicht an Stripe übertragen werden.' const NOT_SENDABLE = 'Diese Rechnung kann nicht mehr gesendet werden.' const PAYMENT_NOTE = 'Bitte geben Sie bei Überweisung die Rechnungsnummer als Verwendungszweck an.' const BASE_ITEMS = [ @@ -353,9 +359,10 @@ test('sendInvoiceNow: Entwurf wird finalisiert (ohne Auto-Advance) und gesendet' const draft = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' }) st.calls.length = 0 const sent = await sendInvoiceNow(draft.id) + // zuerst der Stripe-Kunde (N1), dann finalisieren und senden assert.deepEqual( writes().map((c) => c.path.replace(draft.id, ':id')), - ['/v1/invoices/:id/finalize', '/v1/invoices/:id/send'] + ['/v1/customers/cus_c1', '/v1/invoices/:id/finalize', '/v1/invoices/:id/send'] ) assert.equal(callsTo('POST', /\/finalize$/)[0].body.auto_advance, 'false') assert.equal(sent.status, 'offen') @@ -680,3 +687,216 @@ test('createProjectInvoice: Rechnung nach dem Senden nicht lesbar -> 502 mit inv return true }) }) + +// ---------- Nachtrag N1: Kundendaten vor dem Finalisieren eines Entwurfs ---------- + +test('sendInvoiceNow (N1): ein Entwurf bringt den Stripe-Kunden vor dem Finalisieren auf die aktuellen WOMS-Daten', async () => { + const draft = await createProjectInvoice({ customerId: 'addr1', items: [BASE_ITEMS[1]], createdBy: 'k' }) + const stripeCustomerId = st.invoices.get(draft.id).customer + // zwischen Entwurf und Senden hat sich beim Stripe-Kunden (oder im WOMS) etwas geaendert: Stripe traegt wieder alte Daten + st.customers.set(stripeCustomerId, { + ...st.customers.get(stripeCustomerId), name: 'Anna Alt', email: 'alt@example.invalid', + address: { line1: 'Alte Str. 9', postal_code: '80331', city: 'München', country: 'DE' }, + }) + st.calls.length = 0 + const sent = await sendInvoiceNow(draft.id) + + assert.equal(sent.status, 'offen') + assert.deepEqual( + writes().map((c) => c.path.replace(draft.id, ':id')), + [`/v1/customers/${stripeCustomerId}`, '/v1/invoices/:id/finalize', '/v1/invoices/:id/send'] + ) + assert.deepEqual(callsTo('POST', `/v1/customers/${stripeCustomerId}`)[0].body, { + email: 'neu@example.invalid', name: 'Adresse GmbH', + 'address[line1]': 'Hauptstr. 1', 'address[postal_code]': '10115', 'address[city]': 'Berlin', 'address[country]': 'DE', + }) + const stored = st.customers.get(stripeCustomerId) + 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' }) + // die Rechnung wurde mit den aktuellen Daten festgeschrieben + assert.equal(sent.customerEmail, 'neu@example.invalid') + assert.equal(sent.customerName, 'Adresse GmbH') +}) + +test('sendInvoiceNow (N1): scheitert die Uebertragung, wird nicht finalisiert (502, feste Meldung), ein neuer Versuch geht', async () => { + const draft = await createProjectInvoice({ customerId: 'addr1', items: [BASE_ITEMS[1]], createdBy: 'k' }) + const stripeCustomerId = st.invoices.get(draft.id).customer + st.calls.length = 0 + st.failNext({ method: 'POST', path: `/v1/customers/${stripeCustomerId}`, status: 400, message: 'geheime Stripe-Meldung' }) + await assert.rejects(sendInvoiceNow(draft.id), (err) => { + assert.equal(err.name, 'InvoiceError') + assert.equal(err.status, 502) + assert.equal(err.message, SYNC_FAILED) + assert.equal(err.invoiceId, undefined) + assert.ok(err.cause, 'Ursache bleibt fuers Protokoll') + return true + }) + assert.equal(callsTo('POST', /\/(finalize|send)$/).length, 0) + assert.equal(st.invoices.get(draft.id).status, 'draft') + const sent = await sendInvoiceNow(draft.id) + assert.equal(sent.status, 'offen') +}) + +test('sendInvoiceNow (N1): Entwurf ohne webklarCustomerId (z. B. aus dem Stripe-Dashboard) -> keine Uebertragung, Senden wie bisher', async () => { + st.customers.set('cus_dash', { id: 'cus_dash', object: 'customer', name: 'Dashboard', email: 'dash@example.invalid', metadata: {} }) + st.invoices.set('in_dash', projectInvoice({ id: 'in_dash', customer: 'cus_dash', metadata: {}, total: 90_000 })) + const sent = await sendInvoiceNow('in_dash') + assert.equal(sent.status, 'offen') + assert.deepEqual(writes().map((c) => c.path), ['/v1/invoices/in_dash/finalize', '/v1/invoices/in_dash/send']) + assert.equal(callsTo('POST', /^\/v1\/customers/).length, 0) +}) + +test('sendInvoiceNow (N1): fehlt die E-Mail nur beim Stripe-Kunden, traegt der Abgleich sie aus dem WOMS nach und der Entwurf wird gesendet', async () => { + st.customers.set('cus_nomail2', { id: 'cus_nomail2', object: 'customer', name: 'Ohne Mail', email: null, metadata: { webklarCustomerId: 'addr1' } }) + st.invoices.set('in_nomail2', projectInvoice({ id: 'in_nomail2', customer: 'cus_nomail2', metadata: { webklarCustomerId: 'addr1' } })) + const sent = await sendInvoiceNow('in_nomail2') + assert.equal(sent.status, 'offen') + assert.equal(st.customers.get('cus_nomail2').email, 'neu@example.invalid') + assert.deepEqual(writes().map((c) => c.path), ['/v1/customers/cus_nomail2', '/v1/invoices/in_nomail2/finalize', '/v1/invoices/in_nomail2/send']) +}) + +test('sendInvoiceNow (N1): offene Rechnung wird nur erneut gesendet, ohne Kundenabgleich und ohne Adresspruefung', async () => { + st.customers.set('cus_noaddr', { id: 'cus_noaddr', object: 'customer', name: 'Ohne Adresse', email: 'ohne@example.invalid', metadata: { webklarCustomerId: 'noAddr' } }) + st.invoices.set('in_open_big', projectInvoice({ id: 'in_open_big', customer: 'cus_noaddr', status: 'open', number: 'WK-0009', total: 90_000, metadata: { webklarCustomerId: 'noAddr' } })) + const sent = await sendInvoiceNow('in_open_big') + assert.equal(sent.status, 'offen') + assert.deepEqual(writes().map((c) => c.path), ['/v1/invoices/in_open_big/send']) +}) + +test('sendInvoiceNow (N1): Entwurf eines unbekannten oder nicht festen Kunden -> 400 vor jedem Schreibzugriff', async () => { + st.customers.set('cus_gone', { id: 'cus_gone', object: 'customer', name: 'Weg', email: 'weg@example.invalid', metadata: { webklarCustomerId: 'gibtEsNicht' } }) + st.invoices.set('in_gone', projectInvoice({ id: 'in_gone', customer: 'cus_gone', metadata: { webklarCustomerId: 'gibtEsNicht' } })) + await rejectsWith(sendInvoiceNow('in_gone'), 400, 'Kunde nicht gefunden.') + st.customers.set('cus_lead', { id: 'cus_lead', object: 'customer', name: 'Lead', email: 'lead@example.invalid', metadata: { webklarCustomerId: 'lead1' } }) + st.invoices.set('in_lead', projectInvoice({ id: 'in_lead', customer: 'cus_lead', metadata: { webklarCustomerId: 'lead1' } })) + await rejectsWith(sendInvoiceNow('in_lead'), 400, 'Rechnungen nur für feste Kunden.') + assert.equal(writes().length, 0) +}) + +// ---------- Nachtrag N2: Adress-Sperre beim Senden (§ 14 Abs. 4 Nr. 1 UStG, Kleinbetragsrechnung bis 250 €) ---------- + +const bigItems = (cents) => [{ description: 'Großauftrag', quantity: 1, unitAmountCents: cents }] + +test('createProjectInvoice (N2): senden=true ohne vollstaendige Anschrift und Summe ueber 250 € -> 409 vor jedem Stripe-Aufruf', async () => { + for (const customerId of ['noAddr', 'noPlz', 'noCity', 'noStreet']) { + await rejectsWith(createProjectInvoice({ customerId, items: bigItems(25_001), senden: true, createdBy: 'k' }), 409, ADDRESS_INCOMPLETE) + } + // mehrere Positionen: die Summe zaehlt, nicht die einzelne Position + await rejectsWith( + createProjectInvoice({ customerId: 'noAddr', items: [{ description: 'A', quantity: 2, unitAmountCents: 12_501 }], senden: true, createdBy: 'k' }), + 409, + ADDRESS_INCOMPLETE + ) + assert.equal(st.calls.length, 0, 'weder Lesen noch Schreiben bei Stripe') + assert.equal(st.customers.size, 1) +}) + +test('createProjectInvoice (N2): Kleinbetragsrechnung bis genau 250,00 € darf auch ohne Anschrift gesendet werden', async () => { + const result = await createProjectInvoice({ customerId: 'noAddr', items: bigItems(25_000), senden: true, createdBy: 'k' }) + assert.equal(result.status, 'offen') + assert.equal(result.totalCents, 25_000) +}) + +test('createProjectInvoice (N2): als Entwurf geht jede Summe ohne Anschrift, mit vollstaendiger Anschrift darf auch gesendet werden', async () => { + const draft = await createProjectInvoice({ customerId: 'noAddr', items: bigItems(90_000), createdBy: 'k' }) + assert.equal(draft.status, 'entwurf') + const sent = await createProjectInvoice({ customerId: 'addr1', items: bigItems(90_000), senden: true, createdBy: 'k' }) + assert.equal(sent.status, 'offen') +}) + +test('createProjectInvoice (N2): nach der Sperre ist der Schutz vor doppeltem Anlegen frei, mit ergaenzter Anschrift geht derselbe Aufruf', async () => { + aw.seed('customers', { $id: 'later1', email: 'later@example.invalid', name: 'Spaeter', customerStatus: 'active', status: 'active' }) + const input = { customerId: 'later1', items: bigItems(30_000), senden: true, createdBy: 'k' } + await rejectsWith(createProjectInvoice(input), 409, ADDRESS_INCOMPLETE) + aw.seed('customers', { $id: 'later1', email: 'later@example.invalid', name: 'Spaeter', customerStatus: 'active', status: 'active', street: 'Weg 1', postalCode: '10115', city: 'Berlin' }) + const result = await createProjectInvoice(input) + assert.equal(result.status, 'offen') +}) + +test('sendInvoiceNow (N2): Entwurf ohne vollstaendige Anschrift und Summe ueber 250 € -> 409 vor jedem Schreibzugriff, bis 250,00 € geht es', async () => { + const big = await createProjectInvoice({ customerId: 'noAddr', items: bigItems(25_001), createdBy: 'k' }) + const small = await createProjectInvoice({ customerId: 'noAddr', items: bigItems(25_000), createdBy: 'k' }) + st.calls.length = 0 + await rejectsWith(sendInvoiceNow(big.id), 409, ADDRESS_INCOMPLETE) + assert.equal(writes().length, 0, 'kein Kundenabgleich, kein Finalisieren') + assert.equal(st.invoices.get(big.id).status, 'draft') + assert.equal((await sendInvoiceNow(small.id)).status, 'offen') +}) + +test('sendInvoiceNow (N2): Entwurf mit vollstaendiger Anschrift darf gesendet werden, auch ueber 250 €', async () => { + const draft = await createProjectInvoice({ customerId: 'addr1', items: bigItems(90_000), createdBy: 'k' }) + assert.equal((await sendInvoiceNow(draft.id)).status, 'offen') +}) + +// ---------- Nachtrag N3/N4: Stripe-Kundenfelder ---------- + +test('stripeCustomerFields (N3): Land - zweistelliger Code gross, deutsche Namen ohne Gross-/Kleinschreibung, leer = DE, sonst weglassen', () => { + const country = (value) => stripeCustomerFields({ street: 'Str. 1', postalCode: '1', city: 'X', country: value }).address.country + const hasNoCountry = (value) => !('country' in stripeCustomerFields({ street: 'Str. 1', postalCode: '1', city: 'X', country: value }).address) + for (const code of ['de', 'DE', ' at ', 'Ch', 'fr']) assert.equal(country(code), code.trim().toUpperCase()) + const names = { + Deutschland: 'DE', deutschland: 'DE', DEUTSCHLAND: 'DE', Österreich: 'AT', österreich: 'AT', Ö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', ' schweiz ': 'CH', [`O\u0308sterreich`]: 'AT', + } + for (const [name, code] of Object.entries(names)) assert.equal(country(name), code, name) + // leer oder nicht gesetzt: wie bisher DE + for (const empty of ['', ' ', undefined, null]) assert.equal(country(empty), 'DE', JSON.stringify(empty)) + // unbekannt: kein Land (Stripe behaelt seinen Wert) + for (const unknown of ['Atlantis', 'Germany', 'D', 'DEU', 'Deutschland (DE)']) assert.ok(hasNoCountry(unknown), unknown) + // ohne Strasse bleibt es bei keiner Anschrift + assert.equal(stripeCustomerFields({ country: 'Österreich' }).address, undefined) +}) + +test('stripeCustomerFields (N4): mit Strasse gehen PLZ und Ort immer mit, fehlende als leerer Text (loescht alte Werte)', () => { + assert.deepEqual(stripeCustomerFields({ street: ' Weg 1 ', postalCode: ' ', city: undefined }).address, { line1: 'Weg 1', postal_code: '', city: '', country: 'DE' }) + assert.deepEqual(stripeCustomerFields({ street: 'Weg 1', postalCode: '10115', city: 'Berlin', country: 'AT' }).address, { + line1: 'Weg 1', postal_code: '10115', city: 'Berlin', country: 'AT', + }) + // ohne Strasse keine Anschrift, auch nicht mit PLZ/Ort + assert.equal(stripeCustomerFields({ postalCode: '10115', city: 'Berlin' }).address, undefined) +}) + +test('Stripe-Nachbau (N4): Kunden-Update fuehrt die Anschrift zusammen wie Stripe, ein leerer Text loescht das Feld', async () => { + st.customers.set('cus_m', { id: 'cus_m', object: 'customer', name: 'M', email: 'm@example.invalid', address: { line1: 'Alt 1', postal_code: '80331', city: 'München', country: 'DE' }, metadata: {} }) + const stripe = getStripe() + // nur ein Feld senden: die anderen bleiben + await stripe.customers.update('cus_m', { address: { line1: 'Neu 2' } }) + assert.deepEqual(st.customers.get('cus_m').address, { line1: 'Neu 2', postal_code: '80331', city: 'München', country: 'DE' }) + // leerer Text loescht genau dieses Feld (Stripe liefert dann null) + await stripe.customers.update('cus_m', { address: { postal_code: '', city: '' } }) + assert.deepEqual(st.customers.get('cus_m').address, { line1: 'Neu 2', postal_code: null, city: null, country: 'DE' }) + // ohne address im Aufruf bleibt die Anschrift unberuehrt + await stripe.customers.update('cus_m', { name: 'M2' }) + assert.deepEqual(st.customers.get('cus_m').address, { line1: 'Neu 2', postal_code: null, city: null, country: 'DE' }) + // Kunde ohne Anschrift: das Update legt sie an + st.customers.set('cus_n', { id: 'cus_n', object: 'customer', name: 'N', email: null, address: null, metadata: {} }) + await stripe.customers.update('cus_n', { address: { line1: 'Erst 1', postal_code: '', city: 'Köln' } }) + assert.deepEqual(st.customers.get('cus_n').address, { line1: 'Erst 1', postal_code: null, city: 'Köln' }) +}) + +test('createProjectInvoice (N4): alte PLZ und alter Ort beim Stripe-Kunden werden geloescht, wenn der WOMS-Kunde nur eine Strasse hat', async () => { + aw.seed('customers', { $id: 'strOnly', email: 's@example.invalid', name: 'Nur Strasse', customerStatus: 'active', status: 'active', street: 'Neue Str. 5' }) + st.customers.set('cus_strOnly', { + id: 'cus_strOnly', object: 'customer', name: 'Alt', email: 'alt@example.invalid', + address: { line1: 'Alte Str. 9', postal_code: '80331', city: 'München', country: 'DE' }, metadata: { webklarCustomerId: 'strOnly' }, + }) + await createProjectInvoice({ customerId: 'strOnly', items: [BASE_ITEMS[1]], createdBy: 'k' }) + const [update] = callsTo('POST', '/v1/customers/cus_strOnly') + assert.equal(update.body['address[postal_code]'], '') + assert.equal(update.body['address[city]'], '') + assert.deepEqual(st.customers.get('cus_strOnly').address, { line1: 'Neue Str. 5', postal_code: null, city: null, country: 'DE' }) +}) + +test('createProjectInvoice (N3): unbekanntes Land ueberschreibt das Land beim Stripe-Kunden nicht', async () => { + aw.seed('customers', { $id: 'atlantis', email: 'a@example.invalid', name: 'Atlantis', customerStatus: 'active', status: 'active', street: 'Weg 1', postalCode: '1', city: 'Z', country: 'Atlantis' }) + st.customers.set('cus_atlantis', { + id: 'cus_atlantis', object: 'customer', name: 'Atlantis', email: 'a@example.invalid', + address: { line1: 'Weg 1', postal_code: '1', city: 'Z', country: 'CH' }, metadata: { webklarCustomerId: 'atlantis' }, + }) + await createProjectInvoice({ customerId: 'atlantis', items: [BASE_ITEMS[1]], createdBy: 'k' }) + const [update] = callsTo('POST', '/v1/customers/cus_atlantis') + assert.ok(!('address[country]' in update.body)) + assert.equal(st.customers.get('cus_atlantis').address.country, 'CH') +}) diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index 1ea1b7f..5b95c02 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -28,7 +28,8 @@ import http from 'node:http' * 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). + * email, name, address, metadata; unbekannte ID -> 404). Wie Stripe fuehrt ein Update die Anschrift feldweise zusammen + * (nur gesendete Felder aendern sich) und ein leerer Text loescht das Feld (gespeichert als null). */ // Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/ @@ -153,6 +154,16 @@ export async function startFakeStripe() { return root } + // Anschrift eines Kunden: ein Hash wird feldweise in die bestehende Anschrift geschrieben, '' loescht das Feld (null); + // ein leerer Text statt eines Hashes loescht die ganze Anschrift (wie Stripe) + const mergeAddress = (current, incoming) => { + if (incoming === undefined) return current ?? null + if (typeof incoming !== 'object' || incoming === null) return incoming === '' ? null : (current ?? null) + const merged = { ...(current || {}) } + for (const [field, value] of Object.entries(incoming)) merged[field] = value === '' ? null : value + return merged + } + const route = (method, path, query, body) => { let m if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) { @@ -513,7 +524,7 @@ export async function startFakeStripe() { object: 'customer', email: data.email ?? null, name: data.name ?? null, - address: data.address ?? null, + address: mergeAddress(null, data.address), invoice_settings: data.invoice_settings || {}, metadata: data.metadata || {}, } @@ -531,7 +542,7 @@ export async function startFakeStripe() { ...customer, ...(data.email !== undefined ? { email: data.email || null } : {}), ...(data.name !== undefined ? { name: data.name || null } : {}), - ...(data.address !== undefined ? { address: data.address } : {}), + ...(data.address !== undefined ? { address: mergeAddress(customer.address, data.address) } : {}), metadata, } customers.set(customer.id, updated)