Finanzen: Nachtrag vor Livegang (Kundenabgleich beim Senden, Adress-Sperre, Land und Anschrift sauber an Stripe)
- sendInvoiceNow: Entwurf mit webklarCustomerId laedt den WOMS-Kunden und bringt den Stripe-Kunden vor dem
Finalisieren auf die aktuellen Daten (Fehler: 502, nichts finalisiert); ohne Kennung kein Abgleich
- Adress-Sperre (§ 14 Abs. 4 Nr. 1 UStG): Senden ueber 250 € ohne Strasse/PLZ/Ort -> 409 vor jedem Stripe-Schreibzugriff
(Anlegen mit senden=true und Entwurf senden; Text und Grenze wie im WOMS)
- stripeCustomerFields: Land aus deutschen Namen (unbekannt -> weglassen), PLZ/Ort mit Strasse immer mitsenden ('' loescht)
- Stripe-Nachbau: Kunden-Update fuehrt die Anschrift feldweise zusammen, '' loescht ein Feld
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
73811e0300
commit
f0b57ebe41
5 files changed
+377
-26
No files matched your search
@@ -176,6 +176,24 @@ async function syncStripeCustomer(stripe, stripeCustomerId, customer) {
|
||||
}
|
||||
}
|
||||
|
||||
// Kleinbetragsrechnung (§ 33 UStDV): bis 250 € braucht der Empfaenger keine Anschrift auf der Rechnung. Wert und Text
|
||||
// wie im WOMS (financeView.js: SMALL_INVOICE_MAX_CENTS, ADDRESS_INCOMPLETE_TEXT).
|
||||
const SMALL_INVOICE_MAX_CENTS = 25_000
|
||||
const ADDRESS_INCOMPLETE_MESSAGE = 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.'
|
||||
|
||||
const hasText = (value) => typeof value === 'string' && value.trim() !== ''
|
||||
|
||||
/**
|
||||
* Adress-Sperre vor dem Senden (§ 14 Abs. 4 Nr. 1 UStG): Fehlt beim WOMS-Kunden Strasse, PLZ oder Ort (nur Leerzeichen
|
||||
* zaehlt als fehlend) und liegt die Summe ueber 250 €, gibt es status 409 mit fester Meldung. Gilt nur fuer das Senden
|
||||
* (Entwuerfe ohne Versand und erneutes Senden einer schon festgeschriebenen Rechnung bleiben unberuehrt) und wird vor dem
|
||||
* ersten Stripe-Schreibzugriff geprueft.
|
||||
*/
|
||||
function assertAddressForSend(customer, totalCents) {
|
||||
const complete = hasText(customer.street) && hasText(customer.postalCode) && hasText(customer.city)
|
||||
if (!complete && totalCents > SMALL_INVOICE_MAX_CENTS) throw httpError(409, ADDRESS_INCOMPLETE_MESSAGE)
|
||||
}
|
||||
|
||||
/** Rechnungsfuss: Kleinunternehmer-Hinweis, Bankdaten (falls gesetzt), Hinweis auf den Verwendungszweck. */
|
||||
export function invoiceFooter(bankDetailsText = '') {
|
||||
const bank = typeof bankDetailsText === 'string' ? bankDetailsText.trim() : ''
|
||||
@@ -223,17 +241,20 @@ async function loadBillableCustomer(customerId) {
|
||||
return customer
|
||||
}
|
||||
|
||||
/** Eine Rechnung frisch von Stripe (mit Kunde), gemappt; unbekannte Rechnung -> status 404. */
|
||||
async function readInvoice(invoiceId) {
|
||||
/** Eine Rechnung frisch von Stripe (mit Kunde), roh; unbekannte Rechnung -> status 404. */
|
||||
async function retrieveInvoice(invoiceId) {
|
||||
if (typeof invoiceId !== 'string' || !/^in_\w+$/.test(invoiceId)) throw httpError(404, 'Rechnung nicht gefunden.')
|
||||
let raw
|
||||
try {
|
||||
raw = await getStripe().invoices.retrieve(invoiceId, { expand: ['customer'] })
|
||||
return await getStripe().invoices.retrieve(invoiceId, { expand: ['customer'] })
|
||||
} catch (err) {
|
||||
if (err?.statusCode === 404 || err?.code === 'resource_missing') throw httpError(404, 'Rechnung nicht gefunden.')
|
||||
throw err
|
||||
}
|
||||
return mapInvoice(raw, { today: berlinDate() })
|
||||
}
|
||||
|
||||
/** Eine Rechnung frisch von Stripe (mit Kunde), gemappt; unbekannte Rechnung -> status 404. */
|
||||
async function readInvoice(invoiceId) {
|
||||
return mapInvoice(await retrieveInvoice(invoiceId), { today: berlinDate() })
|
||||
}
|
||||
|
||||
// Finalisieren ohne Auto-Advance (die Rechnung bleibt in unserer Hand), dann per E-Mail senden. Scheitert nur das
|
||||
@@ -250,6 +271,9 @@ async function finalizeAndSend(stripe, invoiceId) {
|
||||
return number
|
||||
}
|
||||
|
||||
/** Kennung des Stripe-Kunden einer rohen Rechnung (expandiert oder als Text). */
|
||||
const stripeCustomerIdOf = (raw) => (raw.customer && typeof raw.customer === 'object' ? raw.customer.id : raw.customer)
|
||||
|
||||
/** Fehler 502 an einer Rechnung, die bei Stripe schon besteht: traegt invoiceId und invoiceNumber (und die Ursache) fuer die Seite. */
|
||||
function invoiceExistsError(message, invoiceId, number, cause) {
|
||||
const err = httpError(502, message)
|
||||
@@ -298,7 +322,9 @@ function validateIdempotencyKey(key) {
|
||||
* settings.js). Mit senden=true wird sie sofort finalisiert und per E-Mail gesendet (offen), sonst bleibt sie ein
|
||||
* Entwurf. Fehler mit status 400 bei ungueltigen Eingaben (auch Summe ueber 99.999.999 Cent), einem Kunden, der noch
|
||||
* Lead/verloren ist, oder fehlender E-Mail-Adresse bei senden=true (alles vor dem ersten Stripe-Aufruf, ebenso ein
|
||||
* Fehler beim Lesen der Einstellungen). Vor der Rechnung wird der Stripe-Kunde (auch ein schon bestehender) auf die
|
||||
* Fehler beim Lesen der Einstellungen). Mit senden=true und unvollstaendiger Anschrift des Kunden (Strasse, PLZ, Ort)
|
||||
* bei einer Summe ueber 250 €: status 409 mit fester Meldung, ebenfalls vor dem ersten Stripe-Aufruf (Entwuerfe ohne
|
||||
* Senden sind davon nicht betroffen). Vor der Rechnung wird der Stripe-Kunde (auch ein schon bestehender) auf die
|
||||
* aktuellen WOMS-Kundendaten gebracht (E-Mail, Name, Anschrift); scheitert das: status 502 „Kundendaten konnten nicht
|
||||
* an Stripe übertragen werden.“ ohne invoiceId, es entsteht keine Rechnung. Scheitert nur das Senden nach dem Festschreiben: status 502 mit invoiceId und
|
||||
* invoiceNumber (die offene Rechnung bleibt, erneut senden mit sendInvoiceNow); ebenso, wenn die fertige Rechnung nicht
|
||||
@@ -344,6 +370,7 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke
|
||||
const customer = await loadBillableCustomer(customerId)
|
||||
// Ohne Adresse lehnt Stripe das Senden erst nach dem Festschreiben ab: lieber vorab pruefen
|
||||
if (senden && !String(customer.email || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE)
|
||||
if (senden) assertAddressForSend(customer, lines.reduce((sum, line) => sum + line.quantity * line.unitAmountCents, 0))
|
||||
|
||||
const { bankDetailsText } = await getSettings()
|
||||
const stripe = getStripe()
|
||||
@@ -401,23 +428,39 @@ async function createInvoice({ customerId, lines, daysUntilDue, note, senden, ke
|
||||
|
||||
/**
|
||||
* Projektrechnung senden: ein Entwurf wird finalisiert (ohne Auto-Advance) und gesendet; eine offene oder ueberfaellige
|
||||
* wird nur erneut gesendet (Erinnerung, kein Finalisieren). Fehler: 409 bei Abo-Rechnungen (verwaltet Stripe) und bei
|
||||
* bezahlten/stornierten/uneinbringlichen, 400 ohne E-Mail-Adresse des Kunden, 404 unbekannt. Scheitert beim Entwurf
|
||||
* nur das Senden nach dem Festschreiben: status 502 mit invoiceId und invoiceNumber (die Rechnung ist dann offen und
|
||||
* kann erneut gesendet werden).
|
||||
* wird nur erneut gesendet (Erinnerung, kein Finalisieren, keine Pruefung der Kundendaten). Bei einem Entwurf mit
|
||||
* webklarCustomerId wird der Kunde aus dem WOMS geladen (400 bei unbekanntem oder nicht festem Kunden), die Adress-Sperre
|
||||
* geprueft (409, siehe createProjectInvoice) und der Stripe-Kunde vor dem Finalisieren auf die aktuellen Kundendaten
|
||||
* gebracht (scheitert das: status 502, nichts wird finalisiert). Ein Entwurf ohne webklarCustomerId (Stripe-Dashboard)
|
||||
* wird ohne beides gesendet. Fehler: 409 bei Abo-Rechnungen (verwaltet Stripe) und bei bezahlten/stornierten/
|
||||
* uneinbringlichen, 400 ohne E-Mail-Adresse des Kunden, 404 unbekannt. Scheitert beim Entwurf nur das Senden nach dem
|
||||
* Festschreiben: status 502 mit invoiceId und invoiceNumber (die Rechnung ist dann offen und kann erneut gesendet werden).
|
||||
*/
|
||||
export async function sendInvoiceNow(invoiceId) {
|
||||
const invoice = await readInvoice(invoiceId)
|
||||
const raw = await retrieveInvoice(invoiceId)
|
||||
const invoice = mapInvoice(raw, { today: berlinDate() })
|
||||
// Ein Abo-Entwurf gehoert Stripe: Finalisieren mit auto_advance=false wuerde den Einzug des Abos anhalten
|
||||
if (invoice.source !== 'projekt') throw httpError(409, ABO_MESSAGE)
|
||||
const isDraft = invoice.status === 'entwurf'
|
||||
if (!isDraft && invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') {
|
||||
throw httpError(409, 'Diese Rechnung kann nicht mehr gesendet werden.')
|
||||
}
|
||||
if (!String(invoice.customerEmail || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE)
|
||||
// Nur ein Entwurf wird festgeschrieben: erst dann zaehlen Kundendaten und Anschrift. Eine offene Rechnung steht schon
|
||||
// fest (Anschrift und Empfaenger sind festgeschrieben), sie wird nur erneut gesendet.
|
||||
let customer = null
|
||||
if (isDraft && invoice.webklarCustomerId) customer = await loadBillableCustomer(invoice.webklarCustomerId)
|
||||
// Nach dem Abgleich traegt der Stripe-Kunde die E-Mail des WOMS-Kunden; ohne die gilt die, die Stripe schon hat
|
||||
const email = String(customer?.email || '').trim() || String(invoice.customerEmail || '').trim()
|
||||
if (!email) throw httpError(400, NO_EMAIL_MESSAGE)
|
||||
if (customer) assertAddressForSend(customer, invoice.totalCents)
|
||||
try {
|
||||
if (isDraft) await finalizeAndSend(getStripe(), invoice.id)
|
||||
else await getStripe().invoices.sendInvoice(invoice.id)
|
||||
if (isDraft) {
|
||||
// Der Entwurf kann Wochen alt sein: den Stripe-Kunden vor dem Festschreiben auf die aktuellen Kundendaten bringen
|
||||
// (Name, E-Mail, Anschrift stehen danach unveraenderlich auf der Rechnung). Ohne Kennung (z. B. Entwurf aus dem
|
||||
// Stripe-Dashboard) gibt es keine WOMS-Daten zum Abgleichen.
|
||||
if (customer) await syncStripeCustomer(getStripe(), stripeCustomerIdOf(raw), customer)
|
||||
await finalizeAndSend(getStripe(), invoice.id)
|
||||
} else await getStripe().invoices.sendInvoice(invoice.id)
|
||||
return await readInvoice(invoice.id)
|
||||
} finally {
|
||||
invalidateFinanceCache()
|
||||
|
||||
@@ -431,23 +431,53 @@ export async function grantCreditForInvoice(invoice, { fresh = false } = {}) {
|
||||
// Checkout / Kundenportal / Kuendigung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Deutsche Laendernamen (klein geschrieben) -> ISO-Code fuer Stripe; die Liste deckt die ueblichen Faelle ab
|
||||
const COUNTRY_CODES = {
|
||||
deutschland: 'DE',
|
||||
österreich: 'AT',
|
||||
oesterreich: 'AT',
|
||||
schweiz: 'CH',
|
||||
niederlande: 'NL',
|
||||
belgien: 'BE',
|
||||
frankreich: 'FR',
|
||||
italien: 'IT',
|
||||
spanien: 'ES',
|
||||
polen: 'PL',
|
||||
dänemark: 'DK',
|
||||
luxemburg: 'LU',
|
||||
liechtenstein: 'LI',
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO-Code fuer das Land eines WOMS-Kunden: zweistelliger Code -> gross; deutscher Name (ohne Gross-/Kleinschreibung) ->
|
||||
* Code; leer -> DE; alles andere -> null (das Land bleibt dann bei Stripe unberuehrt, statt falsch auf DE zu springen).
|
||||
*/
|
||||
function stripeCountry(value) {
|
||||
const raw = typeof value === 'string' ? value.normalize('NFC').trim() : ''
|
||||
if (!raw) return 'DE'
|
||||
if (/^[A-Za-z]{2}$/.test(raw)) return raw.toUpperCase()
|
||||
return COUNTRY_CODES[raw.toLowerCase()] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe-Kundendaten aus dem WOMS-Kunden (Appwrite-Dokument): E-Mail, Name (Firmenname vor Name) und, nur wenn eine
|
||||
* Strasse eingetragen ist, die Anschrift (Land: zweistelliger Code, sonst DE). Leere Werte fehlen (undefined), damit
|
||||
* Stripe sie nicht anfasst. Gemeinsam fuer das Anlegen und das Aktualisieren des Stripe-Kunden.
|
||||
* Strasse eingetragen ist, die Anschrift. Mit Strasse gehen PLZ und Ort immer mit (fehlende als '': Stripe loescht dann
|
||||
* einen alten Wert, statt ihn neben der neuen Strasse stehen zu lassen). Land: siehe stripeCountry (unbekannt -> fehlt).
|
||||
* Leere Werte fehlen (undefined), damit Stripe sie nicht anfasst. Gemeinsam fuer das Anlegen und das Aktualisieren des
|
||||
* Stripe-Kunden.
|
||||
*/
|
||||
export function stripeCustomerFields(customer) {
|
||||
const t = (v) => (typeof v === 'string' ? v.trim() : '')
|
||||
const country = t(customer.country)
|
||||
const country = stripeCountry(customer.country)
|
||||
return {
|
||||
email: t(customer.email) || undefined,
|
||||
name: t(customer.companyName) || t(customer.name) || undefined,
|
||||
address: t(customer.street)
|
||||
? {
|
||||
line1: t(customer.street),
|
||||
postal_code: t(customer.postalCode) || undefined,
|
||||
city: t(customer.city) || undefined,
|
||||
country: country.length === 2 ? country.toUpperCase() : 'DE',
|
||||
postal_code: t(customer.postalCode),
|
||||
city: t(customer.city),
|
||||
...(country ? { country } : {}),
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user