Finanzen: Rechnung senden ohne Doppelanlage (E-Mail vorab, erneut senden, Summengrenze)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
05e3478229
commit
3cedb5feef
2 files changed
+137
-15
No files matched your search
@@ -24,6 +24,11 @@ aw.seed('customers', { $id: 'lead1', email: 'lead@example.invalid', name: 'Inter
|
||||
aw.seed('customers', { $id: 'lost1', email: 'lost@example.invalid', name: 'Verloren', customerStatus: 'Lost', status: 'active' })
|
||||
aw.seed('customers', { $id: 'act1', email: 'act@example.invalid', name: 'Aktiv', companyName: 'Aktiv GmbH', customerStatus: 'active', status: 'active' })
|
||||
|
||||
aw.seed('customers', { $id: '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 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 },
|
||||
@@ -228,12 +233,30 @@ test('createProjectInvoice: Eingaben werden geprueft (Positionen, Texte, Mengen,
|
||||
assert.equal(writes().length, 0, 'bei ungueltigen Eingaben darf nichts an Stripe gehen')
|
||||
})
|
||||
|
||||
test('createProjectInvoice: Grenzwerte sind erlaubt (20 Positionen, 200 Zeichen, Menge 999, 10.000.000 Cent, Ziel 60)', async () => {
|
||||
const items = Array.from({ length: 20 }, (_, i) => ({ description: i === 0 ? 'x'.repeat(200) : `Position ${i}`, quantity: 999, unitAmountCents: 10_000_000 }))
|
||||
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, 20 * 999 * 10_000_000)
|
||||
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 () => {
|
||||
@@ -256,12 +279,80 @@ test('sendInvoiceNow: Entwurf wird finalisiert (ohne Auto-Advance) und gesendet'
|
||||
assert.match(sent.number, /^WK-\d{4}$/)
|
||||
})
|
||||
|
||||
test('sendInvoiceNow: nur Entwuerfe (409), nur Projektrechnungen, unbekannte Rechnung 404', async () => {
|
||||
st.invoices.set('in_open', projectInvoice({ id: 'in_open', status: 'open', number: 'WK-0001' }))
|
||||
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 }))
|
||||
await rejectsWith(sendInvoiceNow('in_open'), 409, 'Nur Entwürfe können gesendet werden.')
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user