Files
Webklar-Kundenbereich/test/financeStripeWrite.test.js
ClaudeandClaude Sonnet 5.5 c0c1a3d7a9 Finanzen: Laendersuche ohne Prototypschluessel, beim Anlegen des Stripe-Kunden keine leeren Adressfelder
- stripeCountry: Object.hasOwn statt Zugriff ueber die Prototypkette ('constructor', '__proto__' ergaben ein Objekt als Land)
- stripeCustomerFields(customer, { forCreate }): beim Anlegen (getOrCreateStripeCustomer) fehlen leere PLZ/Ort ganz;
  das Loeschen per '' bleibt beim Abgleich (Aktualisieren)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00

930 lines
54 KiB
JavaScript

import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber stripeBilling) geladen wird
const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions') } })
const st = await startFakeStripe()
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, 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'
)
after(() => Promise.all([aw.close(), st.close()]))
seedFixtures(aw)
aw.seed('customers', { $id: 'lead1', email: 'lead@example.invalid', name: 'Interessent', customerStatus: 'lead', status: 'active' })
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: '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 = [
{ description: 'Website-Anpassung', quantity: 2, unitAmountCents: 5000 },
{ description: 'Domain', quantity: 1, unitAmountCents: 1200 },
]
beforeEach(() => {
mock.restoreAll()
invalidateFinanceCache()
resetInvoiceCreateGuardForTests()
st.calls.length = 0
st.invoices.clear()
st.customers.clear()
// Das Abo von c1 kennt die Stripe-Kunden-ID cus_c1 (seedFixtures); der Nachbau soll den Kunden ebenfalls kennen
st.customers.set('cus_c1', { id: 'cus_c1', object: 'customer', name: 'Kunde Eins', email: 'k@example.invalid', metadata: { webklarCustomerId: 'c1' } })
})
const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path))
const writes = () => st.calls.filter((c) => c.method !== 'GET')
const rejectsWith = (promise, status, message) =>
assert.rejects(promise, (err) => {
assert.equal(err.status, status, `Status ${err.status}: ${err.message}`)
if (message) assert.equal(err.message, message)
return true
})
const aboInvoice = (over = {}) => ({
id: 'in_abo',
object: 'invoice',
number: 'WK-0100',
status: 'open',
customer: 'cus_c1',
subscription: 'sub_h',
created: Math.floor(Date.now() / 1000),
due_date: null,
total: 2000,
amount_remaining: 2000,
amount_paid: 0,
metadata: {},
lines: { object: 'list', data: [], has_more: false },
...over,
})
const projectInvoice = (over = {}) => ({
id: 'in_proj',
object: 'invoice',
number: null,
status: 'draft',
customer: 'cus_c1',
created: Math.floor(Date.now() / 1000),
due_date: null,
total: 6000,
amount_remaining: 6000,
amount_paid: 0,
metadata: { quelle: 'woms' },
lines: { object: 'list', data: [], has_more: false },
...over,
})
test('invoiceFooter: Kleinunternehmer-Hinweis, Bankdaten (falls gesetzt), Verwendungszweck-Satz', () => {
assert.equal(invoiceFooter(), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
assert.equal(invoiceFooter(''), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
assert.equal(invoiceFooter(' \n '), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
assert.equal(
invoiceFooter('IBAN DE00 0000 0000 0000 0000 00\nBIC TESTDEFF'),
`${config.stripe.invoiceFooter}\nIBAN DE00 0000 0000 0000 0000 00\nBIC TESTDEFF\n${PAYMENT_NOTE}`
)
})
test('createProjectInvoice: Entwurf mit Positionen, Rechnungsfuss (Bankdaten aus den Einstellungen) und Metadaten (nicht gesendet)', async () => {
// Der Bankverbindungstext kommt aus financeSettings/main (siehe settings.js), nicht aus dem Aufruf
aw.seed(config.collections.financeSettings, { $id: 'main', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00' })
let result
try {
result = await createProjectInvoice({
customerId: 'c1',
items: BASE_ITEMS,
memo: 'Leistungen im September',
createdBy: 'kenso@example.invalid',
})
} finally {
await deleteDocument(config.collections.financeSettings, 'main')
}
const [create] = callsTo('POST', '/v1/invoices')
assert.ok(create, 'POST /v1/invoices fehlt')
assert.equal(create.body.customer, 'cus_c1')
assert.equal(create.body.collection_method, 'send_invoice')
assert.equal(create.body.days_until_due, '14')
assert.equal(create.body.auto_advance, 'false')
assert.equal(create.body.pending_invoice_items_behavior, 'exclude')
assert.equal(create.body.currency, 'eur')
assert.equal(create.body.description, 'Leistungen im September')
assert.equal(create.body['metadata[quelle]'], 'woms')
assert.equal(create.body['metadata[webklarCustomerId]'], 'c1')
assert.equal(create.body['metadata[erstelltVon]'], 'kenso@example.invalid')
assert.ok(create.body.footer.startsWith(config.stripe.invoiceFooter))
assert.ok(create.body.footer.endsWith(PAYMENT_NOTE))
assert.equal(create.body.footer, `${config.stripe.invoiceFooter}\nIBAN DE00 0000 0000 0000 0000 00\n${PAYMENT_NOTE}`)
// Menge und Einzelpreis jeder Position kommen bei Stripe an: im Aufruf ...
const items = callsTo('POST', '/v1/invoiceitems')
assert.equal(items.length, 2)
assert.deepEqual(items[0].body, {
customer: 'cus_c1',
invoice: result.id,
description: 'Website-Anpassung',
quantity: '2',
unit_amount_decimal: '5000',
currency: 'eur',
})
assert.deepEqual(items[1].body, {
customer: 'cus_c1',
invoice: result.id,
description: 'Domain',
quantity: '1',
unit_amount_decimal: '1200',
currency: 'eur',
})
// ... und auf der Rechnung (Zeilen mit Menge und Einzelpreis, Summe aus Menge mal Einzelpreis)
const lines = st.invoices.get(result.id).lines.data
assert.deepEqual(
lines.map((l) => [l.description, l.quantity, l.pricing.unit_amount_decimal, l.amount]),
[
['Website-Anpassung', 2, '5000', 10000],
['Domain', 1, '1200', 1200],
]
)
assert.equal(result.status, 'entwurf')
assert.equal(result.source, 'projekt')
assert.equal(result.totalCents, 11200)
assert.equal(result.remainingCents, 11200)
assert.equal(result.webklarCustomerId, 'c1')
assert.equal(result.customerName, 'Kunde Eins')
assert.equal(result.number, '')
// kein Finalisieren, kein Senden
assert.equal(callsTo('POST', /finalize|send/).length, 0)
})
test('createProjectInvoice: ohne Vermerk keine Beschreibung, ohne Bankdaten kuerzerer Fuss, Zahlungsziel 0 bleibt 0', async () => {
await createProjectInvoice({ customerId: 'c1', items: [BASE_ITEMS[1]], daysUntilDue: 0, createdBy: 'k' })
const [create] = callsTo('POST', '/v1/invoices')
assert.equal(create.body.days_until_due, '0')
assert.equal('description' in create.body, false)
assert.equal(create.body.footer, `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
})
test('createProjectInvoice: legt fuer Kunden ohne Stripe-Kunde einen an (Firmenname)', async () => {
const result = await createProjectInvoice({ customerId: 'act1', items: [BASE_ITEMS[1]], createdBy: 'k' })
const created = callsTo('POST', '/v1/customers')
assert.equal(created.length, 1)
assert.equal(created[0].body.name, 'Aktiv GmbH')
assert.equal(result.webklarCustomerId, 'act1')
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' })
const order = writes().map((c) => c.path.replace(/in_test_\d+/, ':id'))
assert.deepEqual(order.slice(-4), ['/v1/invoiceitems', '/v1/invoiceitems', '/v1/invoices/:id/finalize', '/v1/invoices/:id/send'])
const [finalize] = callsTo('POST', /\/finalize$/)
assert.equal(finalize.body.auto_advance, 'false')
assert.equal(result.status, 'offen')
assert.match(result.number, /^WK-\d{4}$/)
assert.equal(result.totalCents, 11200)
assert.equal(result.dueDate, berlinDate(new Date(Date.now() + 30 * 86_400_000)))
assert.ok(result.hostedUrl)
})
test('createProjectInvoice: Lead-, verlorene und unbekannte Kunden werden mit 400 abgelehnt, Stripe bleibt unberuehrt', async () => {
await rejectsWith(createProjectInvoice({ customerId: 'lead1', items: BASE_ITEMS, createdBy: 'k' }), 400, 'Rechnungen nur für feste Kunden.')
await rejectsWith(createProjectInvoice({ customerId: 'lost1', items: BASE_ITEMS, createdBy: 'k' }), 400, 'Rechnungen nur für feste Kunden.')
await rejectsWith(createProjectInvoice({ customerId: 'gibtEsNicht', items: BASE_ITEMS, createdBy: 'k' }), 400)
await rejectsWith(createProjectInvoice({ items: BASE_ITEMS, createdBy: 'k' }), 400)
assert.equal(writes().length, 0)
})
test('createProjectInvoice: Eingaben werden geprueft (Positionen, Texte, Mengen, Betraege, Zahlungsziel)', async () => {
const ok = BASE_ITEMS[1]
const bad = async (label, input) => {
try {
await createProjectInvoice({ customerId: 'c1', items: [ok], createdBy: 'k', ...input })
} catch (err) {
assert.equal(err.status, 400, `${label}: Status ${err.status} (${err.message})`)
return
}
assert.fail(`${label}: wurde nicht abgelehnt`)
}
const item = (over) => ({ ...ok, ...over })
// 21 Positionen, keine Positionen, keine Liste
await rejectsWith(createProjectInvoice({ customerId: 'c1', items: Array.from({ length: 21 }, () => ok), createdBy: 'k' }), 400)
await bad('leer', { items: [] })
await bad('keine Liste', { items: undefined })
// Beschreibung
await bad('Beschreibung leer', { items: [item({ description: '' })] })
await bad('Beschreibung nur Leerzeichen', { items: [item({ description: ' ' })] })
await bad('Beschreibung 201 Zeichen', { items: [item({ description: 'x'.repeat(201) })] })
await bad('Beschreibung keine Zeichenkette', { items: [item({ description: 5 })] })
// Menge
for (const quantity of [0, 1000, 1.5, '2', -1, null]) await bad(`Menge ${quantity}`, { items: [item({ quantity })] })
// Einzelpreis
for (const unitAmountCents of [0, 10_000_001, 12.5, '5000', -100, null]) await bad(`Preis ${unitAmountCents}`, { items: [item({ unitAmountCents })] })
// Zahlungsziel
for (const daysUntilDue of [-1, 61, 1.5, '14', null]) await bad(`Ziel ${daysUntilDue}`, { daysUntilDue })
assert.equal(writes().length, 0, 'bei ungueltigen Eingaben darf nichts an Stripe gehen')
})
test('createProjectInvoice: Rechnungssumme hoechstens 99.999.999 Cent, genau die Grenze ist erlaubt', async () => {
const TOO_HIGH = 'Rechnungssumme zu hoch.'
// eine Position allein ueber der Grenze (999 x 10.000.000) und mehrere zusammen (9 x 10.000.000 + 10.000.000)
await rejectsWith(createProjectInvoice({ customerId: 'c1', items: [{ description: 'Gross', quantity: 999, unitAmountCents: 10_000_000 }], createdBy: 'k' }), 400, TOO_HIGH)
const nine = Array.from({ length: 9 }, (_, i) => ({ description: `Teil ${i + 1}`, quantity: 1, unitAmountCents: 10_000_000 }))
await rejectsWith(createProjectInvoice({ customerId: 'c1', items: [...nine, { description: 'Rest', quantity: 1, unitAmountCents: 10_000_000 }], createdBy: 'k' }), 400, TOO_HIGH)
// vor jedem Stripe-Aufruf, auch vor dem Anlegen des Stripe-Kunden
assert.equal(st.calls.length, 0)
// genau 99.999.999 Cent (9 x 10.000.000 + 9.999.999)
const result = await createProjectInvoice({ customerId: 'c1', items: [...nine, { description: 'Rest', quantity: 1, unitAmountCents: 9_999_999 }], createdBy: 'k' })
assert.equal(result.totalCents, 99_999_999)
})
test('createProjectInvoice: Grenzwerte sind erlaubt (20 Positionen, 200 Zeichen, Menge 999, Einzelpreis 10.000.000 Cent, Ziel 60)', async () => {
// 19 Positionen mit Menge 999, dazu eine mit dem hoechsten Einzelpreis: zusammen unter der Summengrenze
const items = Array.from({ length: 19 }, (_, i) => ({ description: i === 0 ? 'x'.repeat(200) : `Position ${i}`, quantity: 999, unitAmountCents: 1000 }))
items.push({ description: 'Teuer', quantity: 1, unitAmountCents: 10_000_000 })
const result = await createProjectInvoice({ customerId: 'c1', items, daysUntilDue: 60, createdBy: 'k' })
assert.equal(callsTo('POST', '/v1/invoiceitems').length, 20)
assert.equal(result.totalCents, 19 * 999 * 1000 + 10_000_000)
assert.equal(callsTo('POST', '/v1/invoices')[0].body.days_until_due, '60')
assert.equal(callsTo('POST', '/v1/invoiceitems')[19].body.unit_amount_decimal, '10000000')
assert.equal(callsTo('POST', '/v1/invoiceitems')[0].body.quantity, '999')
})
test('createProjectInvoice: scheitert eine Position, wird der halbfertige Entwurf geloescht', async () => {
st.failNext({ method: 'POST', path: '/v1/invoiceitems', status: 400, message: 'Nachbau: Position abgelehnt', after: 1 })
await assert.rejects(createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' }), /Position abgelehnt/)
assert.equal(callsTo('DELETE', /^\/v1\/invoices\/in_/).length, 1)
assert.equal(st.invoices.size, 0)
})
test('sendInvoiceNow: Entwurf wird finalisiert (ohne Auto-Advance) und gesendet', async () => {
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/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')
assert.match(sent.number, /^WK-\d{4}$/)
})
test('createProjectInvoice: senden=true ohne E-Mail-Adresse des Kunden -> 400 vor jedem Stripe-Aufruf, als Entwurf geht es', async () => {
for (const customerId of ['noMail', 'blankMail']) {
await rejectsWith(createProjectInvoice({ customerId, items: BASE_ITEMS, senden: true, createdBy: 'k' }), 400, NO_MAIL)
}
assert.equal(st.calls.length, 0)
const draft = await createProjectInvoice({ customerId: 'noMail', items: BASE_ITEMS, createdBy: 'k' })
assert.equal(draft.status, 'entwurf')
})
test('createProjectInvoice: scheitert das Senden nach dem Festschreiben -> 502 mit Rechnungs-ID und -Nummer, Rechnung bleibt offen', async () => {
st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Nachbau: Versand abgelehnt' })
const err = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, senden: true, createdBy: 'k' }).then(
() => assert.fail('haette scheitern muessen'),
(e) => e
)
assert.equal(err.status, 502)
const [stored] = [...st.invoices.values()]
assert.equal(err.invoiceId, stored.id)
assert.equal(err.invoiceNumber, stored.number)
assert.equal(err.message, `Rechnung ${stored.number} wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.`)
assert.equal(stored.status, 'open')
// Zwischenspeicher geleert: die Liste zeigt die offene Rechnung sofort
assert.deepEqual((await listInvoices()).map((i) => [i.id, i.status]), [[stored.id, 'offen']])
// erneutes Senden: nur "send", nicht noch einmal finalisieren, keine zweite Rechnung
st.calls.length = 0
const resent = await sendInvoiceNow(err.invoiceId)
assert.deepEqual(writes().map((c) => c.path.replace(err.invoiceId, ':id')), ['/v1/invoices/:id/send'])
assert.equal(resent.status, 'offen')
assert.equal(resent.number, stored.number)
assert.equal(st.invoices.size, 1)
})
test('sendInvoiceNow: scheitert das Senden eines Entwurfs nach dem Festschreiben -> 502 mit Nummer', async () => {
const draft = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' })
st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Nachbau: Versand abgelehnt' })
const err = await sendInvoiceNow(draft.id).then(
() => assert.fail('haette scheitern muessen'),
(e) => e
)
assert.equal(err.status, 502)
assert.equal(err.invoiceId, draft.id)
assert.match(err.invoiceNumber, /^WK-\d{4}$/)
assert.equal(err.message, `Rechnung ${err.invoiceNumber} wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.`)
assert.equal(st.invoices.get(draft.id).status, 'open')
assert.equal(callsTo('POST', /\/finalize$/).length, 1)
// der Versand-Fehler einer reinen Erinnerung (offen) kommt dagegen unveraendert von Stripe
st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Nachbau: Versand abgelehnt' })
await assert.rejects(sendInvoiceNow(draft.id), (e) => e.status === undefined && /Versand abgelehnt/.test(e.message))
})
test('sendInvoiceNow: offene und ueberfaellige Projektrechnungen werden nur erneut gesendet (kein Finalisieren)', async () => {
st.invoices.set('in_open', projectInvoice({ id: 'in_open', status: 'open', number: 'WK-0001', due_date: Math.floor(Date.now() / 1000) + 5 * 86_400 }))
st.invoices.set('in_late', projectInvoice({ id: 'in_late', status: 'open', number: 'WK-0002', due_date: Math.floor(Date.now() / 1000) - 5 * 86_400 }))
assert.equal((await sendInvoiceNow('in_open')).status, 'offen')
assert.equal((await sendInvoiceNow('in_late')).status, 'ueberfaellig')
assert.deepEqual(writes().map((c) => c.path), ['/v1/invoices/in_open/send', '/v1/invoices/in_late/send'])
})
test('sendInvoiceNow: bezahlt/storniert/uneinbringlich -> 409, Abo-Rechnungen -> 409, ohne E-Mail -> 400, unbekannt -> 404', async () => {
for (const status of ['paid', 'void', 'uncollectible']) st.invoices.set(`in_${status}`, projectInvoice({ id: `in_${status}`, status, number: 'WK-0003' }))
st.invoices.set('in_abo', aboInvoice({ status: 'draft', number: null }))
st.invoices.set('in_abo_open', aboInvoice({ id: 'in_abo_open' }))
for (const status of ['paid', 'void', 'uncollectible']) await rejectsWith(sendInvoiceNow(`in_${status}`), 409, NOT_SENDABLE)
await rejectsWith(sendInvoiceNow('in_abo'), 409, 'Abo-Rechnungen verwaltet Stripe.')
await rejectsWith(sendInvoiceNow('in_abo_open'), 409, 'Abo-Rechnungen verwaltet Stripe.')
await rejectsWith(sendInvoiceNow('in_gibtEsNicht'), 404, 'Rechnung nicht gefunden.')
// Kunde ohne E-Mail-Adresse: Entwurf und offene Rechnung scheitern mit 400 vor jedem Schreibzugriff
st.customers.set('cus_nomail', { id: 'cus_nomail', object: 'customer', name: 'Ohne Mail', email: null, metadata: {} })
st.invoices.set('in_nomail_draft', projectInvoice({ id: 'in_nomail_draft', customer: 'cus_nomail' }))
st.invoices.set('in_nomail_open', projectInvoice({ id: 'in_nomail_open', customer: 'cus_nomail', status: 'open', number: 'WK-0004' }))
await rejectsWith(sendInvoiceNow('in_nomail_draft'), 400, NO_MAIL)
await rejectsWith(sendInvoiceNow('in_nomail_open'), 400, NO_MAIL)
assert.equal(writes().length, 0)
})
test('voidInvoice: offene und ueberfaellige Projektrechnungen werden storniert', async () => {
const open = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, senden: true, createdBy: 'k' })
const voided = await voidInvoice(open.id)
assert.equal(voided.status, 'storniert')
assert.equal(callsTo('POST', `/v1/invoices/${open.id}/void`).length, 1)
// ueberfaellig: offen, Faelligkeit vor heute
st.invoices.set('in_late', projectInvoice({ id: 'in_late', status: 'open', number: 'WK-0007', due_date: Math.floor(Date.now() / 1000) - 10 * 86_400 }))
assert.equal((await voidInvoice('in_late')).status, 'storniert')
})
test('voidInvoice: Entwurf, bezahlte und Abo-Rechnungen werden mit 409 abgelehnt, unbekannte mit 404', async () => {
st.invoices.set('in_draft', projectInvoice({ id: 'in_draft' }))
st.invoices.set('in_paid', projectInvoice({ id: 'in_paid', status: 'paid', number: 'WK-0002' }))
st.invoices.set('in_abo', aboInvoice())
await rejectsWith(voidInvoice('in_draft'), 409)
await rejectsWith(voidInvoice('in_paid'), 409)
await rejectsWith(voidInvoice('in_abo'), 409, 'Abo-Rechnungen verwaltet Stripe.')
await rejectsWith(voidInvoice('in_gibtEsNicht'), 404, 'Rechnung nicht gefunden.')
assert.equal(writes().length, 0)
assert.equal(st.invoices.get('in_draft').status, 'draft')
})
test('deleteDraft: loescht einen Projekt-Entwurf bei Stripe', async () => {
const draft = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' })
const result = await deleteDraft(draft.id)
assert.equal(result, undefined)
assert.equal(callsTo('DELETE', `/v1/invoices/${draft.id}`).length, 1)
assert.equal(st.invoices.has(draft.id), false)
})
test('deleteDraft: Abo-Rechnungen und alles ausser Entwuerfen werden mit 409 abgelehnt', async () => {
st.invoices.set('in_abo', aboInvoice())
st.invoices.set('in_abo_draft', aboInvoice({ id: 'in_abo_draft', status: 'draft', number: null }))
st.invoices.set('in_open', projectInvoice({ id: 'in_open', status: 'open', number: 'WK-0003' }))
await rejectsWith(deleteDraft('in_abo'), 409, 'Abo-Rechnungen verwaltet Stripe.')
await rejectsWith(deleteDraft('in_abo_draft'), 409, 'Abo-Rechnungen verwaltet Stripe.')
await rejectsWith(deleteDraft('in_open'), 409)
await rejectsWith(deleteDraft('in_gibtEsNicht'), 404, 'Rechnung nicht gefunden.')
assert.equal(writes().length, 0)
assert.equal(st.invoices.size, 3)
})
test('Schreibzugriffe leeren den Zwischenspeicher: die Liste zeigt neue, gesendete, stornierte und geloeschte Rechnungen sofort', async () => {
assert.deepEqual(await listInvoices(), [])
const draft = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' })
assert.deepEqual((await listInvoices()).map((i) => [i.id, i.status]), [[draft.id, 'entwurf']])
await sendInvoiceNow(draft.id)
assert.equal((await listInvoices())[0].status, 'offen')
await voidInvoice(draft.id)
assert.equal((await listInvoices())[0].status, 'storniert')
// gleicher Inhalt wie der erste Entwurf: der Schutz vor doppeltem Anlegen waere noch aktiv
resetInvoiceCreateGuardForTests()
const second = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' })
assert.equal((await listInvoices()).length, 2)
await deleteDraft(second.id)
assert.deepEqual((await listInvoices()).map((i) => i.id), [draft.id])
})
test('Stripe-Nachbau: Statusuebergaenge wie Stripe (finalize nur Entwurf, send/void/pay nur offen, loeschen nur Entwurf)', async () => {
const stripe = getStripe()
const status = (id) => st.invoices.get(id).status
const refused = (promise, pattern) => assert.rejects(promise, (err) => err.statusCode === 400 && pattern.test(err.message))
const create = () => stripe.invoices.create({ customer: 'cus_c1', collection_method: 'send_invoice', days_until_due: 7 })
const item = (id) => stripe.invoiceItems.create({ customer: 'cus_c1', invoice: id, description: 'Test', quantity: 3, unit_amount_decimal: '250', currency: 'eur' })
// send_invoice braucht ein Zahlungsziel
await assert.rejects(stripe.invoices.create({ customer: 'cus_c1', collection_method: 'send_invoice' }), (err) => err.statusCode === 400)
const draft = await create()
assert.equal(draft.status, 'draft')
await item(draft.id)
assert.equal(st.invoices.get(draft.id).total, 750)
await refused(stripe.invoices.sendInvoice(draft.id), /open/)
await refused(stripe.invoices.voidInvoice(draft.id), /open/)
await refused(stripe.invoices.pay(draft.id), /open/)
// Metadaten zusammenfuehren, leerer Wert loescht
await stripe.invoices.update(draft.id, { metadata: { a: '1', b: '2' } })
await stripe.invoices.update(draft.id, { metadata: { b: '', c: '3' } })
assert.deepEqual(st.invoices.get(draft.id).metadata, { a: '1', c: '3' })
const open = await stripe.invoices.finalizeInvoice(draft.id, { auto_advance: false })
assert.equal(open.status, 'open')
assert.match(open.number, /^WK-\d{4}$/)
assert.equal(open.auto_advance, false)
await refused(stripe.invoices.finalizeInvoice(draft.id), /draft/)
await refused(stripe.invoices.del(draft.id), /draft/)
await refused(item(draft.id), /draft/)
assert.equal(status(draft.id), 'open')
// Nummern laufen fortlaufend
const second = await create()
assert.equal((await stripe.invoices.finalizeInvoice(second.id)).number, `WK-${String(Number(open.number.slice(3)) + 1).padStart(4, '0')}`)
// bezahlen (ausserhalb von Stripe) und danach nichts mehr
const paid = await stripe.invoices.pay(draft.id, { paid_out_of_band: true })
assert.equal(paid.status, 'paid')
assert.equal(paid.amount_paid, 750)
assert.equal(paid.amount_remaining, 0)
// wie die echte API: die Rechnung traegt paid_out_of_band nicht; der Aufruf (calls) hat es gesendet
assert.equal(paid.paid_out_of_band, undefined)
assert.equal(st.calls.findLast((c) => c.path === `/v1/invoices/${draft.id}/pay`).body.paid_out_of_band, 'true')
assert.ok(paid.status_transitions.paid_at)
await refused(stripe.invoices.voidInvoice(draft.id), /open/)
await refused(stripe.invoices.pay(draft.id), /open/)
// stornieren nur offen; Entwurf loeschen
await stripe.invoices.voidInvoice(second.id)
assert.equal(status(second.id), 'void')
const third = await create()
const removed = await stripe.invoices.del(third.id)
assert.equal(removed.deleted, true)
assert.equal(st.invoices.has(third.id), false)
})
// ---------- Doppeltes Anlegen (I3) ----------
const createdCalls = () => callsTo('POST', '/v1/invoices')
test('createProjectInvoice: idempotencyKey geht als Idempotency-Key nur an die Anlage der Rechnung (I3)', async () => {
await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, idempotencyKey: 'woms-formular-4711', createdBy: 'k@example.invalid' })
const [create] = createdCalls()
assert.equal(create.idempotencyKey, 'woms-formular-4711')
// die Posten tragen nicht den Schluessel des Aufrufers (stripe-node vergibt sonst eigene, zufaellige)
assert.ok(callsTo('POST', '/v1/invoiceitems').every((call) => call.idempotencyKey !== 'woms-formular-4711'))
// ohne Schluessel (oder leer / null) nimmt stripe-node einen eigenen, zufaelligen: nie den des Aufrufers
st.calls.length = 0
for (const idempotencyKey of [undefined, null, '']) {
resetInvoiceCreateGuardForTests()
await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, idempotencyKey })
}
assert.equal(createdCalls().length, 3)
assert.equal(new Set(createdCalls().map((call) => call.idempotencyKey)).size, 3, 'jedes Mal ein anderer')
})
test('createProjectInvoice: ungueltiger Wiederholungsschluessel -> 400, nichts bei Stripe (I3)', async () => {
for (const idempotencyKey of ['x'.repeat(101), 'mit Leerzeichen', 'Zeilen\numbruch', 42, {}]) {
await rejectsWith(
createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, idempotencyKey }),
400,
'Der Wiederholungsschlüssel ist ungültig (1 bis 100 Zeichen ohne Leerzeichen).'
)
}
assert.equal(st.calls.length, 0)
// genau 100 Zeichen sind erlaubt
await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, idempotencyKey: 'k'.repeat(100) })
assert.equal(createdCalls()[0].idempotencyKey, 'k'.repeat(100))
})
test('createProjectInvoice: zwei gleichzeitige gleiche Aufrufe legen nur eine Rechnung an, der zweite bekommt 409 (I3)', async () => {
const args = { customerId: 'c1', items: BASE_ITEMS, memo: 'Doppelklick', senden: true }
const results = await Promise.allSettled([createProjectInvoice(args), createProjectInvoice(args)])
assert.deepEqual(results.map((r) => r.status), ['fulfilled', 'rejected'])
await rejectsWith(Promise.reject(results[1].reason), 409, 'Diese Rechnung wird gerade schon erstellt.')
assert.equal(st.invoices.size, 1)
assert.equal(createdCalls().length, 1)
assert.equal(callsTo('POST', /\/send$/).length, 1, 'nur eine Mail')
})
test('createProjectInvoice: 30 Sekunden nach einem Erfolg 409, danach wieder erlaubt; anderer Inhalt ist nie betroffen (I3)', async () => {
const args = { customerId: 'c1', items: BASE_ITEMS }
await createProjectInvoice(args)
await rejectsWith(createProjectInvoice(args), 409, 'Diese Rechnung wird gerade schon erstellt.')
assert.equal(st.invoices.size, 1)
// anderer Inhalt: anderer Vermerk, anderes Zahlungsziel, senden, anderer Kunde, andere Positionen
await createProjectInvoice({ ...args, memo: 'anders' })
await createProjectInvoice({ ...args, daysUntilDue: 30 })
await createProjectInvoice({ ...args, senden: true })
await createProjectInvoice({ customerId: 'act1', items: BASE_ITEMS })
await createProjectInvoice({ customerId: 'c1', items: [{ ...BASE_ITEMS[0], quantity: 3 }] })
assert.equal(st.invoices.size, 6)
// nach 30 Sekunden gilt dieselbe Rechnung wieder als neu
const real = Date.now
mock.method(Date, 'now', () => real() + 29_000)
await rejectsWith(createProjectInvoice(args), 409)
mock.method(Date, 'now', () => real() + 31_000)
await createProjectInvoice(args)
assert.equal(st.invoices.size, 7)
})
test('createProjectInvoice: scheitert die Anlage und bleibt keine Rechnung, ist der Schutz frei (I3)', async () => {
const args = { customerId: 'c1', items: BASE_ITEMS }
st.failNext({ method: 'POST', path: '/v1/invoices', status: 400, message: 'abgelehnt' })
await assert.rejects(createProjectInvoice(args))
// Ungueltiger Kunde zaehlt ebenso als gescheitert
await rejectsWith(createProjectInvoice({ customerId: 'lead1', items: BASE_ITEMS }), 400)
await rejectsWith(createProjectInvoice({ customerId: 'lead1', items: BASE_ITEMS }), 400)
// Posten scheitert: Entwurf wird geloescht, der Schutz freigegeben
st.failNext({ method: 'POST', path: '/v1/invoiceitems', status: 400, message: 'abgelehnt' })
await assert.rejects(createProjectInvoice(args))
assert.equal(st.invoices.size, 0)
await createProjectInvoice(args)
assert.equal(st.invoices.size, 1)
})
test('createProjectInvoice: Senden scheitert nach dem Festschreiben -> Rechnung besteht, der Schutz bleibt (I3)', async () => {
const args = { customerId: 'c1', items: BASE_ITEMS, senden: true }
st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Mailversand abgelehnt' })
await assert.rejects(createProjectInvoice(args), (err) => err.status === 502 && /^in_/.test(err.invoiceId))
await rejectsWith(createProjectInvoice(args), 409, 'Diese Rechnung wird gerade schon erstellt.')
assert.equal(st.invoices.size, 1)
})
test('createProjectInvoice: Rechnung nach dem Senden nicht lesbar -> 502 mit invoiceId und Nummer statt blankem Fehler (I3)', async () => {
const args = { customerId: 'c1', items: BASE_ITEMS, senden: true }
st.failNext({ method: 'GET', path: /^\/v1\/invoices\/in_/, status: 400, message: 'Lesefehler 10.0.0.7' })
let caught
await assert.rejects(createProjectInvoice(args), (err) => {
caught = err
return true
})
assert.equal(caught.status, 502)
assert.equal(caught.name, 'InvoiceError')
assert.match(caught.invoiceId, /^in_/)
assert.match(caught.invoiceNumber, /^WK-\d{4}$/)
assert.equal(caught.message, `Rechnung ${caught.invoiceNumber} wurde gesendet, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.`)
assert.ok(!caught.message.includes('10.0.0.7'))
assert.ok(caught.cause, 'die Ursache bleibt fuers Protokoll')
assert.equal(st.invoices.get(caught.invoiceId).status, 'open')
assert.equal(callsTo('POST', /\/send$/).length, 1)
// die Rechnung besteht: ein zweiter Versuch mit gleichem Inhalt wird abgewiesen
await rejectsWith(createProjectInvoice(args), 409)
// Entwurf: ohne Nummer, andere Meldung
resetInvoiceCreateGuardForTests()
st.failNext({ method: 'GET', path: /^\/v1\/invoices\/in_/, status: 400 })
await assert.rejects(createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS }), (err) => {
assert.equal(err.status, 502)
assert.equal(err.invoiceNumber, '')
assert.equal(err.message, 'Der Entwurf wurde angelegt, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.')
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)
// Namen von Object.prototype sind keine Laender (kein Treffer ueber die Prototypkette)
for (const proto of ['constructor', 'Constructor', '__proto__', 'toString', 'hasOwnProperty', 'valueOf']) assert.ok(hasNoCountry(proto), proto)
// 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('stripeCustomerFields (forCreate): beim Anlegen fehlen leere PLZ/Ort ganz, mit Werten bleibt alles gleich', () => {
assert.deepEqual(stripeCustomerFields({ street: 'Weg 1', postalCode: ' ', city: undefined }, { forCreate: true }).address, { line1: 'Weg 1', country: 'DE' })
assert.deepEqual(stripeCustomerFields({ street: 'Weg 1', postalCode: '10115', city: '' }, { forCreate: true }).address, { line1: 'Weg 1', postal_code: '10115', country: 'DE' })
assert.deepEqual(
stripeCustomerFields({ street: 'Weg 1', postalCode: '10115', city: 'Berlin', country: 'AT' }, { forCreate: true }).address,
stripeCustomerFields({ street: 'Weg 1', postalCode: '10115', city: 'Berlin', country: 'AT' }).address
)
assert.equal(stripeCustomerFields({ postalCode: '10115' }, { forCreate: true }).address, undefined)
})
test('createProjectInvoice (forCreate): ein neuer Stripe-Kunde mit nur einer Strasse wird ohne leere Adressfelder angelegt, der Abgleich danach loescht per ""', async () => {
aw.seed('customers', { $id: 'newStr', email: 'ns@example.invalid', name: 'Neu Strasse', customerStatus: 'active', status: 'active', street: 'Neue Str. 5' })
await createProjectInvoice({ customerId: 'newStr', items: [BASE_ITEMS[1]], createdBy: 'k' })
const [created] = callsTo('POST', '/v1/customers')
assert.equal(created.body['address[line1]'], 'Neue Str. 5')
assert.ok(!('address[postal_code]' in created.body), 'keine leere PLZ beim Anlegen')
assert.ok(!('address[city]' in created.body), 'kein leerer Ort beim Anlegen')
assert.deepEqual(Object.keys(created.body).filter((k) => k.startsWith('address[')), ['address[line1]', 'address[country]'])
assert.ok(Object.entries(created.body).filter(([k]) => k.startsWith('address[')).every(([, v]) => v !== ''), 'leerer Adresswert im Anlege-Aufruf')
// der Abgleich direkt danach (Aktualisieren) behaelt das Loeschen per leerem Text
const [update] = callsTo('POST', /^\/v1\/customers\/cus_test_/)
assert.equal(update.body['address[postal_code]'], '')
assert.equal(update.body['address[city]'], '')
})
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')
})