483 lines
25 KiB
JavaScript
483 lines
25 KiB
JavaScript
import { test, after, beforeEach } 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 } = await import('../server/services/stripeBilling.js')
|
|
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
|
const { invoiceFooter, createProjectInvoice, sendInvoiceNow, voidInvoice, deleteDraft, listInvoices, invalidateFinanceCache } = 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: '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 },
|
|
{ description: 'Domain', quantity: 1, unitAmountCents: 1200 },
|
|
]
|
|
|
|
beforeEach(() => {
|
|
invalidateFinanceCache()
|
|
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: 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)
|
|
assert.deepEqual(
|
|
writes().map((c) => c.path.replace(draft.id, ':id')),
|
|
['/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')
|
|
|
|
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)
|
|
})
|