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,
}
+48 -1
View File
@@ -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 })
+222 -2
View File
@@ -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')
})
+14 -3
View File
@@ -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)