Files
Webklar-Kundenbereich/test/auftraegeStripe.test.js
T

289 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
const aw = await startAppwriteMock({
schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') },
datetimes: AUFTRAEGE_DATETIMES,
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { seedRechnungsDaten, legeAuftragAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js')
const {
mapInvoice, createProjectInvoice, rechnungsVorschau, sendInvoiceNow, voidInvoice, deleteDraft, invalidateFinanceCache, resetInvoiceCreateGuardForTests,
} = await import('../server/services/finance/stripeFinance.js')
const { getProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js')
const { getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js')
const { withLocks } = await import('../server/services/keyedLock.js')
const { berlinDate, formatDe } = await import('../server/services/finance/dates.js')
after(() => Promise.all([aw.close(), st.close()]))
seedRechnungsDaten(aw)
config.auftraege.mode = 'on'
beforeEach(async () => {
mock.restoreAll()
await leereRechnungsDaten(aw, st)
invalidateFinanceCache()
resetInvoiceCreateGuardForTests()
})
const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt
const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }]
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 wirft = (promise, status, message) =>
assert.rejects(promise, (err) => {
assert.equal(err.status, status, `Status ${err.status}: ${err.message}`)
assert.equal(err.message, message)
return true
})
test('mapInvoice: Projektfelder aus den Metadaten, art mit Rückfall abo oder frei', () => {
const roh = (over = {}) => ({ id: 'in_1', object: 'invoice', status: 'open', customer: 'cus_1', created: 1_790_000_000, total: 1000, metadata: {}, lines: { object: 'list', data: [] }, ...over })
const felder = (r) => [r.projektId, r.projektName, r.art, r.auftragId, r.auftragNummer]
const heute = { today: '2026-10-06' }
assert.deepEqual(felder(mapInvoice(roh(), heute)), ['', '', 'frei', '', ''])
assert.deepEqual(felder(mapInvoice(roh({ subscription: 'sub_1' }), heute)), ['', '', 'abo', '', ''])
const metadata = { projektId: 'p1', projektName: 'Website Wolf', art: 'schluss', auftragId: 'a1', auftragNummer: 'AB-2026-0002' }
assert.deepEqual(felder(mapInvoice(roh({ metadata }), heute)), ['p1', 'Website Wolf', 'schluss', 'a1', 'AB-2026-0002'])
for (const art of ['quatsch', 'abo', '']) assert.equal(mapInvoice(roh({ metadata: { art } }), heute).art, 'frei', `art "${art}"`)
})
test('createProjectInvoice frei: A4 für jede Rechnung, Art in den Metadaten, keine Zusatzfelder, kein Projektdatensatz', async () => {
const rechnung = await createProjectInvoice({ customerId: 'k1', items: POSTEN, createdBy: 'kenso@webklar.com' })
const [create] = callsTo('POST', '/v1/invoices')
assert.equal(create.body['rendering[pdf][page_size]'], 'a4')
assert.equal(create.body['metadata[art]'], 'frei')
assert.equal(create.body['metadata[webklarCustomerId]'], 'k1')
assert.ok(!Object.keys(create.body).some((k) => k.startsWith('custom_fields')))
assert.ok(!Object.keys(create.body).some((k) => /^metadata\[(projektId|projektName|auftragId|auftragNummer)\]$/.test(k)))
assert.deepEqual([rechnung.art, rechnung.status, rechnung.totalCents, rechnung.projektId], ['frei', 'entwurf', 1200, ''])
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
})
test('createProjectInvoice festbetrag: Zusatzfeld Projekt, Metadaten und ein Projektdatensatz mit der Rechnungs-ID', async () => {
const items = [{ description: 'Erstellung der Website anelia-wolf.com, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }]
const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items, createdBy: 'kenso@webklar.com' })
const [create] = callsTo('POST', '/v1/invoices')
assert.equal(create.body['custom_fields[0][name]'], 'Projekt')
assert.equal(create.body['custom_fields[0][value]'], 'Website anelia-wolf.com')
assert.equal(create.body['custom_fields[1][name]'], undefined)
assert.equal(create.body['rendering[pdf][page_size]'], 'a4')
assert.deepEqual([create.body['metadata[art]'], create.body['metadata[projektId]'], create.body['metadata[projektName]']], ['festbetrag', 'p1', 'Website anelia-wolf.com'])
assert.deepEqual([rechnung.art, rechnung.projektId, rechnung.projektName, rechnung.totalCents], ['festbetrag', 'p1', 'Website anelia-wolf.com', 50000])
const doc = await getProjektRechnung(rechnung.id)
assert.deepEqual(
[doc.invoiceId, doc.kundenId, doc.projektId, doc.auftragId, doc.art, doc.betragCents, doc.rechnungsnummer, doc.aktiv, doc.erstelltVon],
[rechnung.id, 'k1', 'p1', '', 'festbetrag', 50000, '', true, 'kenso@webklar.com']
)
})
test('createProjectInvoice aufwand: eine Zeile je Tag bei Stripe, Blätter im Projektdatensatz, Zeitraum als Zusatzfeld', async () => {
const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2', 'ws3'], createdBy: 'k' })
const items = callsTo('POST', '/v1/invoiceitems')
assert.deepEqual(items.map((c) => [c.body.quantity, c.body.unit_amount_decimal, c.body.amount]), [['1', '22500', undefined], ['1', '10000', undefined]])
assert.ok(items[0].body.description.startsWith('06.10.2026: '))
const [create] = callsTo('POST', '/v1/invoices')
assert.deepEqual([0, 1].map((i) => create.body[`custom_fields[${i}][name]`]), ['Projekt', 'Leistungszeitraum'])
assert.equal(create.body['custom_fields[1][value]'], '06.10.–07.10.2026')
assert.equal(rechnung.totalCents, 32500)
assert.deepEqual((await getProjektRechnung(rechnung.id)).arbeitsblattIds, ['ws1', 'ws2', 'ws3'])
})
test('createProjectInvoice schluss: positive Zeilen mit Menge und Einzelpreis, die Minuszeile nur mit amount (ohne quantity)', async () => {
const ab = await legeAuftragAn()
const anzahlung = await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true, createdBy: 'k' })
assert.deepEqual([anzahlung.totalCents, anzahlung.art, anzahlung.auftragNummer, anzahlung.status], [40000, 'anzahlung', 'AB-2026-0002', 'offen'])
st.calls.length = 0
const schluss = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, createdBy: 'k' })
const [create] = callsTo('POST', '/v1/invoices')
assert.deepEqual(
[0, 1].map((i) => [create.body[`custom_fields[${i}][name]`], create.body[`custom_fields[${i}][value]`]]),
[['Projekt', 'Website anelia-wolf.com'], ['Auftrag', 'AB-2026-0002 vom 06.10.2026']]
)
const items = callsTo('POST', '/v1/invoiceitems')
const kunde = items[0].body.customer
assert.deepEqual(items.map((c) => c.body), [
{ customer: kunde, invoice: schluss.id, description: 'Website', quantity: '1', unit_amount_decimal: '60000', currency: 'eur' },
{ customer: kunde, invoice: schluss.id, description: 'Texte', quantity: '2', unit_amount_decimal: '10000', currency: 'eur' },
{ customer: kunde, invoice: schluss.id, description: `abzüglich Anzahlung, Rechnung ${anzahlung.number} vom ${formatDe(berlinDate())}`, amount: '-40000', currency: 'eur' },
])
assert.deepEqual([schluss.totalCents, schluss.art, schluss.status], [40000, 'schluss', 'entwurf'])
})
test('createProjectInvoice: scheitert der Projektdatensatz, wird der Entwurf bei Stripe gelöscht und nichts bleibt zurück', async () => {
const eingabe = { customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, createdBy: 'k' }
aw.failNext({ collection: config.collections.projektRechnungen, method: 'POST', status: 500 })
await assert.rejects(createProjectInvoice(eingabe))
assert.equal(st.invoices.size, 0)
assert.equal(callsTo('DELETE', /^\/v1\/invoices\/in_/).length, 1)
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
// der Schutz vor doppeltem Anlegen ist wieder frei
assert.equal((await createProjectInvoice(eingabe)).status, 'entwurf')
})
test('createProjectInvoice: der Fingerabdruck unterscheidet Art, Projekt, Auftrag und Anzahlung, gleicher Inhalt bleibt 30 Sekunden gesperrt', async () => {
const basis = { customerId: 'k1', items: POSTEN }
const GESPERRT = 'Diese Rechnung wird gerade schon erstellt.'
await createProjectInvoice(basis)
await createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p1' })
await createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p3' })
await wirft(createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p3' }), 409, GESPERRT)
const ab = await legeAuftragAn()
await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 50 } })
await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 } })
await wirft(createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 } }), 409, GESPERRT)
assert.equal(st.invoices.size, 5)
})
test('rechnungsVorschau: Positionen, Summe, Zeitraum und Zusatzfelder ohne Stripe-Schreibzugriff und ohne Projektdatensatz', async () => {
const v = await rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1' })
assert.deepEqual(Object.keys(v).sort(), ['customFields', 'leistungszeitraum', 'positionen', 'summeCents'])
assert.equal(v.summeCents, 32500)
assert.deepEqual(v.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07'])
assert.equal(v.leistungszeitraum, '06.10.–07.10.2026')
assert.deepEqual(v.customFields.map((f) => f.name), ['Projekt', 'Leistungszeitraum'])
assert.equal(writes().length, 0)
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
})
test('rechnungsVorschau: dieselben Prüfungen wie das Anlegen; Anzahlung ohne Angaben zeigt den Standardwert', async () => {
await createProjectInvoice({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] })
await wirft(rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws3'] }), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
await wirft(rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p2' }), 404, 'Projekt nicht gefunden.')
const ab = await legeAuftragAn()
const v = await rechnungsVorschau({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id })
assert.deepEqual([v.summeCents, v.positionen[0].schluessel], [40000, 'anzahlung'])
assert.equal(aw.docs(config.collections.projektRechnungen).size, 1, 'nur der Entwurf von oben')
})
test('Aufwand: zwei gleichzeitige Rechnungen mit denselben Blättern -> genau eine entsteht, die andere bekommt 409', async () => {
st.delayNext({ method: 'POST', path: '/v1/invoices', ms: 150 })
const eingabe = (memo) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2'], memo, createdBy: 'k' })
const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe('Admin A')), createProjectInvoice(eingabe('Admin B'))])
assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected'])
assert.equal(ergebnisse[1].reason.status, 409)
assert.equal(ergebnisse[1].reason.message, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
assert.equal(st.invoices.size, 1)
assert.equal(callsTo('POST', '/v1/invoices').length, 1)
assert.equal(aw.docs(config.collections.projektRechnungen).size, 1)
})
test('Aufwand: eine nur teilweise überschneidende Auswahl wird abgewiesen, die übrigen Blätter bleiben frei', async () => {
const eingabe = (ids, memo) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ids, memo })
await createProjectInvoice(eingabe(['ws1', 'ws2'], 'A'))
await wirft(createProjectInvoice(eingabe(['ws2', 'ws3'], 'B')), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
const rest = await createProjectInvoice(eingabe(['ws3'], 'C'))
assert.equal(rest.totalCents, 10000)
assert.equal(st.invoices.size, 2)
})
test('Doppelklick (gleicher Inhalt gleichzeitig): der Schutz vor doppeltem Anlegen greift, es entsteht eine Rechnung', async () => {
const eingabe = { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] }
const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe), createProjectInvoice(eingabe)])
assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected'])
assert.equal(ergebnisse[1].reason.message, 'Diese Rechnung wird gerade schon erstellt.')
assert.equal(st.invoices.size, 1)
})
test('Schluss: zwei gleichzeitige Schlussrechnungen zur selben AB -> eine entsteht, die andere stößt auf den Entwurf (409)', async () => {
const ab = await legeAuftragAn()
st.delayNext({ method: 'POST', path: '/v1/invoices', ms: 150 })
const eingabe = (memo) => ({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo })
const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe('A')), createProjectInvoice(eingabe('B'))])
assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected'])
assert.equal(ergebnisse[1].reason.status, 409)
assert.equal(ergebnisse[1].reason.message, `Erst den Entwurf über 800,00${NBSP}€ senden oder löschen.`)
assert.equal(st.invoices.size, 1)
})
test('Finalisieren trägt die Rechnungsnummer in den Projektdatensatz ein (senden und sendInvoiceNow); freie Rechnungen haben keinen Datensatz', async () => {
const gesendet = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, senden: true })
assert.match(gesendet.number, /^WK-\d{4}$/)
assert.equal((await getProjektRechnung(gesendet.id)).rechnungsnummer, gesendet.number)
const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, memo: 'später' })
assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, '')
const nachher = await sendInvoiceNow(entwurf.id)
assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, nachher.number)
const frei = await createProjectInvoice({ customerId: 'k1', items: POSTEN, memo: 'frei', senden: true })
assert.equal(await getProjektRechnung(frei.id), null)
})
test('Schlussrechnung: nach dem Finalisieren steht die AB auf abgerechnet; Anzahlung und Entwurf ändern den Status nicht', async () => {
const ab = await legeAuftragAn()
await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true })
assert.equal((await getAuftrag(ab.id)).status, 'versendet')
const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
assert.equal((await getAuftrag(ab.id)).status, 'versendet')
const gesendet = await sendInvoiceNow(entwurf.id)
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, gesendet.number)
await wirft(createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo: 'noch eine' }), 400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.')
})
test('Storno der Schlussrechnung: Datensatz inaktiv, AB zurück auf bestaetigt (mit bestaetigtAm) bzw. versendet; Storno einer Anzahlung ändert die AB nie', async () => {
for (const [patch, erwartet] of [[{}, 'versendet'], [{ status: 'bestaetigt', bestaetigtAm: '2026-10-07T09:00:00.000Z' }, 'bestaetigt']]) {
await leereRechnungsDaten(aw, st)
resetInvoiceCreateGuardForTests()
const ab = await legeAuftragAn({}, patch)
const anzahlung = await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true })
await voidInvoice(anzahlung.id)
assert.equal((await getProjektRechnung(anzahlung.id)).aktiv, false)
assert.equal((await getAuftrag(ab.id)).status, patch.status ?? 'versendet')
const schluss = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, senden: true })
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
await voidInvoice(schluss.id)
assert.equal((await getProjektRechnung(schluss.id)).aktiv, false)
assert.equal((await getAuftrag(ab.id)).status, erwartet)
// die stornierten Rechnungen zählen nicht: eine neue Schlussrechnung geht über die volle Summe
assert.equal((await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo: 'neu' })).totalCents, 80000)
}
})
test('Entwurf löschen und Storno geben die Arbeitsblätter frei (Datensatz inaktiv)', async () => {
const args = { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'], createdBy: 'k' }
const entwurf = await createProjectInvoice(args)
await wirft(createProjectInvoice({ ...args, memo: 'nochmal' }), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
await deleteDraft(entwurf.id)
assert.equal((await getProjektRechnung(entwurf.id)).aktiv, false)
const offen = await createProjectInvoice({ ...args, memo: 'nochmal', senden: true })
assert.equal(offen.status, 'offen')
await wirft(createProjectInvoice({ ...args, memo: 'drittes Mal' }), 409, `Arbeitsblatt bereits abgerechnet (Rechnung ${offen.number}).`)
await voidInvoice(offen.id)
assert.equal((await getProjektRechnung(offen.id)).aktiv, false)
assert.equal((await createProjectInvoice({ ...args, memo: 'viertes Mal' })).status, 'entwurf')
})
test('Scheitert das Buchen nach dem Finalisieren, wird die Rechnung trotzdem gesendet (nur Protokoll); erneutes Senden trägt die Nummer nach', async () => {
const log = mock.method(console, 'error', () => {})
aw.failNext({ collection: config.collections.projektRechnungen, method: 'PATCH', status: 500 })
const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, senden: true })
assert.equal(rechnung.status, 'offen')
assert.equal(callsTo('POST', /\/send$/).length, 1)
assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('Rechnungsnummer buchen')))
assert.equal((await getProjektRechnung(rechnung.id)).rechnungsnummer, '')
const erneut = await sendInvoiceNow(rechnung.id)
assert.equal(erneut.status, 'offen')
assert.equal((await getProjektRechnung(rechnung.id)).rechnungsnummer, rechnung.number)
})
test('Die AB ändert L2 nur unter der AB-Sperre: eine laufende Statusänderung eines anderen Strangs wird nicht überholt', async () => {
const ab = await legeAuftragAn()
const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
let freigeben
const gehalten = withLocks(sperreAuftrag(ab.id), () => new Promise((resolve) => { freigeben = resolve }))
const senden = sendInvoiceNow(entwurf.id)
await new Promise((resolve) => setTimeout(resolve, 200))
assert.equal((await getAuftrag(ab.id)).status, 'versendet', 'L2 wartet auf die Sperre der AB')
freigeben()
await Promise.all([gehalten, senden])
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
})