Merge auftraege/l2-rechnungen: Rechnungen (Strang L2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e5fc5e2c74
12 files changed
+2083
-70
No files matched your search
@@ -0,0 +1,52 @@
|
||||
import { test, after } 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'
|
||||
|
||||
const aw = await startAppwriteMock({ schema: {} })
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { getStripe } = await import('../server/services/stripeBilling.js')
|
||||
after(() => Promise.all([aw.close(), st.close()]))
|
||||
|
||||
const refused = (promise, pattern) => assert.rejects(promise, (err) => err.statusCode === 400 && pattern.test(err.message))
|
||||
const create = (extra = {}) => getStripe().invoices.create({ customer: 'cus_x', collection_method: 'send_invoice', days_until_due: 14, ...extra })
|
||||
const item = (invoice, extra) => getStripe().invoiceItems.create({ customer: 'cus_x', invoice, description: 'Posten', currency: 'eur', ...extra })
|
||||
|
||||
test('Stripe-Nachbau: custom_fields und rendering werden gespeichert und mit der Rechnung geliefert', async () => {
|
||||
const felder = [{ name: 'Projekt', value: 'Website anelia-wolf.com' }, { name: 'Auftrag', value: 'AB-2026-0002 vom 06.10.2026' }]
|
||||
const invoice = await create({ custom_fields: felder, rendering: { pdf: { page_size: 'a4' } } })
|
||||
assert.deepEqual(st.invoices.get(invoice.id).custom_fields, felder)
|
||||
assert.deepEqual(st.invoices.get(invoice.id).rendering, { pdf: { page_size: 'a4' } })
|
||||
const gelesen = await getStripe().invoices.retrieve(invoice.id)
|
||||
assert.deepEqual(gelesen.custom_fields, felder)
|
||||
assert.deepEqual(gelesen.rendering, { pdf: { page_size: 'a4' } })
|
||||
await item(invoice.id, { quantity: 1, unit_amount_decimal: '1000' })
|
||||
assert.deepEqual(st.invoices.get(invoice.id).custom_fields, felder, 'Posten ändern die Zusatzfelder nicht')
|
||||
const schlicht = await create()
|
||||
assert.equal(st.invoices.get(schlicht.id).custom_fields, null)
|
||||
assert.equal(st.invoices.get(schlicht.id).rendering, null)
|
||||
})
|
||||
|
||||
test('Stripe-Nachbau: Grenzen der Zusatzfelder (4, Name 40, Wert 140) und der Seitengröße wie bei Stripe', async () => {
|
||||
const viele = Array.from({ length: 5 }, (_, i) => ({ name: `Feld ${i}`, value: 'x' }))
|
||||
await refused(create({ custom_fields: viele }), /up to 4 custom fields/)
|
||||
await refused(create({ custom_fields: [{ name: 'n'.repeat(41), value: 'x' }] }), /names must be between 1 and 40/)
|
||||
await refused(create({ custom_fields: [{ name: 'n', value: 'v'.repeat(141) }] }), /values must be between 1 and 140/)
|
||||
await refused(create({ rendering: { pdf: { page_size: 'din-a4' } } }), /page_size/)
|
||||
const vier = await create({ custom_fields: viele.slice(0, 4).map((f) => ({ ...f, value: 'v'.repeat(140) })), rendering: { pdf: { page_size: 'letter' } } })
|
||||
assert.equal(st.invoices.get(vier.id).custom_fields.length, 4)
|
||||
})
|
||||
|
||||
test('Stripe-Nachbau: Posten mit negativem amount (ohne quantity) mindern die Summe; amount mit quantity oder Einzelpreis wird abgelehnt', async () => {
|
||||
const invoice = await create()
|
||||
await item(invoice.id, { description: 'Leistung', quantity: 1, unit_amount_decimal: '40000' })
|
||||
await item(invoice.id, { description: 'abzüglich Anzahlung', amount: -15000 })
|
||||
const gespeichert = st.invoices.get(invoice.id)
|
||||
assert.deepEqual(gespeichert.lines.data.map((l) => [l.description, l.quantity, l.amount]), [['Leistung', 1, 40000], ['abzüglich Anzahlung', 1, -15000]])
|
||||
assert.deepEqual([gespeichert.total, gespeichert.amount_due], [25000, 25000])
|
||||
await refused(item(invoice.id, { amount: -100, quantity: 2 }), /only specify one of these parameters/)
|
||||
await refused(item(invoice.id, { amount: -100, unit_amount_decimal: '100' }), /only specify one of these parameters/)
|
||||
assert.equal(st.invoices.get(invoice.id).total, 25000)
|
||||
})
|
||||
@@ -0,0 +1,303 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
stundenText, leistungszeitraumText, kalenderwoche, gueltigerTag, kuerzen,
|
||||
festePositionen, zusatzPositionen, pruefePositionen, aufwandPositionen,
|
||||
auftragDatum, anzahlungPosition, schlussPositionen, SCHLUSS_GRENZEN,
|
||||
} from '../server/services/auftraege/positionen.js'
|
||||
|
||||
/** Wirft die Funktion einen AuftragError mit diesem Status und dieser Meldung? */
|
||||
const wirft = (fn, status, message) =>
|
||||
assert.throws(fn, (err) => {
|
||||
assert.equal(err.status, status, `Status ${err.status}: ${err.message}`)
|
||||
assert.equal(err.message, message)
|
||||
return true
|
||||
})
|
||||
const ANZAHL = 'Eine Rechnung braucht 1 bis 20 Positionen.'
|
||||
const ok = { description: 'Domain', quantity: 1, unitAmountCents: 1200 }
|
||||
|
||||
test('stundenText: Minuten als Stunden mit Komma, ohne überflüssige Nullen', () => {
|
||||
const faelle = [[0, '0'], [15, '0,25'], [30, '0,5'], [45, '0,75'], [60, '1'], [90, '1,5'], [135, '2,25'], [390, '6,5'], [600, '10']]
|
||||
for (const [minuten, text] of faelle) assert.equal(stundenText(minuten), text, `${minuten} Minuten`)
|
||||
})
|
||||
|
||||
test('leistungszeitraumText: ein Tag, ein Jahr, Jahreswechsel (Halbgeviertstrich U+2013 ohne Leerzeichen)', () => {
|
||||
assert.equal(leistungszeitraumText('2026-10-06', '2026-10-06'), '06.10.2026')
|
||||
assert.equal(leistungszeitraumText('2026-10-01', '2026-10-20'), '01.10.–20.10.2026')
|
||||
assert.equal(leistungszeitraumText('2026-09-28', '2026-10-05'), '28.09.–05.10.2026')
|
||||
assert.equal(leistungszeitraumText('2026-12-28', '2027-01-05'), '28.12.2026–05.01.2027')
|
||||
})
|
||||
|
||||
test('kalenderwoche: ISO-Woche samt ISO-Jahr, gueltigerTag: nur Tage, die es gibt', () => {
|
||||
const faelle = [
|
||||
['2026-10-05', 2026, 41], ['2026-10-06', 2026, 41], ['2026-10-11', 2026, 41], ['2026-10-12', 2026, 42],
|
||||
['2025-12-29', 2026, 1], ['2026-01-01', 2026, 1], ['2026-12-31', 2026, 53], ['2027-01-03', 2026, 53],
|
||||
['2027-01-04', 2027, 1], ['2024-12-30', 2025, 1], ['2021-01-03', 2020, 53],
|
||||
]
|
||||
for (const [tag, jahr, woche] of faelle) assert.deepEqual(kalenderwoche(tag), { jahr, woche }, tag)
|
||||
assert.throws(() => kalenderwoche('2026-02-30'), RangeError)
|
||||
assert.equal(gueltigerTag('2026-02-28'), true)
|
||||
for (const tag of ['2026-02-30', '2026-13-01', '06.10.2026', '2026-1-1', '', undefined, 20261006]) assert.equal(gueltigerTag(tag), false, String(tag))
|
||||
})
|
||||
|
||||
test('kuerzen: höchstens max Zeichen, Auslassungszeichen am Ende, kein halbes Emoji', () => {
|
||||
assert.equal(kuerzen('kurz', 10), 'kurz')
|
||||
assert.equal(kuerzen('abcdefghij', 10), 'abcdefghij')
|
||||
assert.equal(kuerzen('abcdefghijk', 10), 'abcdefghi…')
|
||||
assert.equal(kuerzen('ab cdefghijk', 4), 'ab…')
|
||||
assert.equal(kuerzen('abcd😀fghij', 6), 'abcd…')
|
||||
})
|
||||
|
||||
test('festePositionen: Eingabe wie bisher, Beschreibung getrimmt, Schlüssel pos-n, Betrag = Menge × Einzelpreis', () => {
|
||||
assert.deepEqual(festePositionen([{ description: ' Website ', quantity: 2, unitAmountCents: 5000 }, ok]), [
|
||||
{ schluessel: 'pos-1', beschreibung: 'Website', menge: 2, einzelpreisCents: 5000, betragCents: 10000 },
|
||||
{ schluessel: 'pos-2', beschreibung: 'Domain', menge: 1, einzelpreisCents: 1200, betragCents: 1200 },
|
||||
])
|
||||
assert.equal(festePositionen(Array.from({ length: 20 }, () => ok)).length, 20)
|
||||
})
|
||||
|
||||
test('festePositionen: Grenzen und Meldungen wie bei der bisherigen Rechnung', () => {
|
||||
wirft(() => festePositionen([]), 400, ANZAHL)
|
||||
wirft(() => festePositionen(undefined), 400, ANZAHL)
|
||||
wirft(() => festePositionen(Array.from({ length: 21 }, () => ok)), 400, ANZAHL)
|
||||
for (const description of ['', ' ', 'x'.repeat(201), 5]) wirft(() => festePositionen([{ ...ok, description }]), 400, 'Position 1: Die Beschreibung braucht 1 bis 200 Zeichen.')
|
||||
for (const quantity of [0, 1000, 1.5, '2', -1, null]) wirft(() => festePositionen([ok, { ...ok, quantity }]), 400, 'Position 2: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
|
||||
for (const unitAmountCents of [0, 10_000_001, 12.5, '5000', -100, null]) {
|
||||
wirft(() => festePositionen([{ ...ok, unitAmountCents }]), 400, 'Position 1: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.')
|
||||
}
|
||||
const neun = Array.from({ length: 9 }, (_, i) => ({ description: `Teil ${i + 1}`, quantity: 1, unitAmountCents: 10_000_000 }))
|
||||
wirft(() => festePositionen([...neun, { description: 'Rest', quantity: 1, unitAmountCents: 10_000_000 }]), 400, 'Rechnungssumme zu hoch.')
|
||||
assert.equal(festePositionen([...neun, { description: 'Rest', quantity: 1, unitAmountCents: 9_999_999 }]).at(-1).betragCents, 9_999_999)
|
||||
})
|
||||
|
||||
test('zusatzPositionen: leer erlaubt, Schlüssel zusatz-n, gleiche Prüfungen; pruefePositionen: Zahl (mit wählbarer Obergrenze), Texte, Summe mit Minuszeilen', () => {
|
||||
for (const leer of [undefined, null, []]) assert.deepEqual(zusatzPositionen(leer), [])
|
||||
assert.deepEqual(zusatzPositionen([{ description: 'Domain', quantity: 2, unitAmountCents: 1500 }]), [
|
||||
{ schluessel: 'zusatz-1', beschreibung: 'Domain', menge: 2, einzelpreisCents: 1500, betragCents: 3000 },
|
||||
])
|
||||
wirft(() => zusatzPositionen('Domain'), 400, ANZAHL)
|
||||
wirft(() => zusatzPositionen(Array.from({ length: 21 }, () => ok)), 400, ANZAHL)
|
||||
wirft(() => zusatzPositionen([{ ...ok, description: '' }]), 400, 'Position 1: Die Beschreibung braucht 1 bis 200 Zeichen.')
|
||||
|
||||
const pos = (beschreibung, cents) => ({ schluessel: 'x', beschreibung, menge: 1, einzelpreisCents: cents, betragCents: cents })
|
||||
assert.equal(pruefePositionen([pos('A', 5000), pos('abzüglich Anzahlung', -2000)]), 3000)
|
||||
assert.equal(pruefePositionen([pos('A', 99_999_999)]), 99_999_999)
|
||||
wirft(() => pruefePositionen([]), 400, ANZAHL)
|
||||
wirft(() => pruefePositionen(Array.from({ length: 21 }, () => pos('A', 1))), 400, ANZAHL)
|
||||
// eine Schlussrechnung darf bis 40 Zeilen haben: der Aufrufer hebt die Obergrenze an
|
||||
assert.equal(pruefePositionen(Array.from({ length: 40 }, () => pos('A', 1)), { max: 40 }), 40)
|
||||
wirft(() => pruefePositionen(Array.from({ length: 41 }, () => pos('A', 1)), { max: 40 }), 400, 'Eine Rechnung braucht 1 bis 40 Positionen.')
|
||||
wirft(() => pruefePositionen([], { max: 40 }), 400, 'Eine Rechnung braucht 1 bis 40 Positionen.')
|
||||
wirft(() => pruefePositionen([pos('A', 1), pos('', 1)]), 400, 'Position 2: Die Beschreibung braucht 1 bis 200 Zeichen.')
|
||||
wirft(() => pruefePositionen([pos('x'.repeat(201), 1)]), 400, 'Position 1: Die Beschreibung braucht 1 bis 200 Zeichen.')
|
||||
wirft(() => pruefePositionen([pos('A', 99_999_999), pos('B', 1)]), 400, 'Rechnungssumme zu hoch.')
|
||||
})
|
||||
|
||||
const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt
|
||||
const blatt = (id, datum, minuten, text = '', over = {}) => ({ id, ticketId: 't1', woid: 'WO-1', datum, minuten, text, aboZeit: false, kommentar: false, ...over })
|
||||
const werktage = (start, n) => {
|
||||
const tage = []
|
||||
for (let ms = Date.parse(`${start}T00:00:00Z`); tage.length < n; ms += 86_400_000) {
|
||||
const tag = new Date(ms)
|
||||
if (tag.getUTCDay() % 6 !== 0) tage.push(tag.toISOString().slice(0, 10))
|
||||
}
|
||||
return tage
|
||||
}
|
||||
|
||||
test('aufwandPositionen: eine Zeile je Tag mit Texten, Stunden und Satz (Menge 1, Betrag = Stunden × Satz)', () => {
|
||||
const erg = aufwandPositionen([blatt('w1', '2026-10-06', 90, 'Startseite, Kontaktformular'), blatt('w2', '2026-10-06', 45, '')], { satzCents: 10000 })
|
||||
assert.deepEqual(erg.positionen, [
|
||||
{ schluessel: '2026-10-06', beschreibung: `06.10.2026: Startseite, Kontaktformular – 2,25 Std. à 100,00${NBSP}€`, menge: 1, einzelpreisCents: 22500, betragCents: 22500 },
|
||||
])
|
||||
assert.deepEqual([erg.minuten, erg.von, erg.bis], [135, '2026-10-06', '2026-10-06'])
|
||||
})
|
||||
|
||||
test('aufwandPositionen: Texte eines Tages mit "; " verbunden, Doppelte entfallen, Leerraum vereinheitlicht, ohne Text "Arbeitszeit"', () => {
|
||||
const erg = aufwandPositionen([
|
||||
blatt('w1', '2026-10-06', 60, 'Startseite'), blatt('w2', '2026-10-06', 60, 'Kontaktformular\nmit Anhang'), blatt('w3', '2026-10-06', 15, ' Startseite '),
|
||||
blatt('w4', '2026-10-07', 30, ''),
|
||||
], { satzCents: 10000 })
|
||||
assert.deepEqual(erg.positionen.map((p) => p.beschreibung), [
|
||||
`06.10.2026: Startseite; Kontaktformular mit Anhang – 2,25 Std. à 100,00${NBSP}€`,
|
||||
`07.10.2026: Arbeitszeit – 0,5 Std. à 100,00${NBSP}€`,
|
||||
])
|
||||
assert.deepEqual(erg.positionen.map((p) => p.betragCents), [22500, 5000])
|
||||
})
|
||||
|
||||
test('aufwandPositionen: Reihenfolge der Eingabe egal (nach Tag und Kennung), von und bis, Satz ohne glatte Cent je Viertelstunde', () => {
|
||||
const erg = aufwandPositionen([blatt('w3', '2026-10-08', 15, 'C'), blatt('w1', '2026-10-06', 15, 'A'), blatt('w2', '2026-10-07', 15, 'B')], { satzCents: 9999 })
|
||||
assert.deepEqual(erg.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07', '2026-10-08'])
|
||||
assert.deepEqual([erg.von, erg.bis, erg.minuten], ['2026-10-06', '2026-10-08', 45])
|
||||
assert.equal(erg.positionen[0].beschreibung, `06.10.2026: A – 0,25 Std. à 99,99${NBSP}€`)
|
||||
assert.equal(erg.positionen[0].betragCents, 2500) // 15 Minuten × 99,99 €/Std. = 24,9975 € → ganze Cent gerundet
|
||||
})
|
||||
|
||||
test('aufwandPositionen: zeilentexte ersetzen den Text vor dem Gedankenstrich; leer oder unbekannt wirkt nicht; Zeile bleibt bei höchstens 200 Zeichen', () => {
|
||||
const standard = `06.10.2026: Startseite – 2,25 Std. à 100,00${NBSP}€`
|
||||
const mit = (zeilentexte, text = 'Startseite') =>
|
||||
aufwandPositionen([blatt('w1', '2026-10-06', 135, text)], { satzCents: 10000, zeilentexte }).positionen[0].beschreibung
|
||||
assert.equal(mit({ '2026-10-06': '06.10.2026: Neue Startseite' }), `06.10.2026: Neue Startseite – 2,25 Std. à 100,00${NBSP}€`)
|
||||
for (const wirkungslos of [undefined, null, {}, { '2026-10-06': ' ' }, { '2026-10-07': 'anderer Tag' }, { '2026-10-06': 5 }]) assert.equal(mit(wirkungslos), standard)
|
||||
const schluss = ` – 2,25 Std. à 100,00${NBSP}€`
|
||||
const ersetzt = mit({ '2026-10-06': 'x'.repeat(300) })
|
||||
assert.equal(ersetzt.length, 200)
|
||||
assert.ok(ersetzt.endsWith(`x…${schluss}`))
|
||||
const lang = mit(undefined, 'y'.repeat(300))
|
||||
assert.equal(lang.length, 200)
|
||||
assert.ok(lang.startsWith('06.10.2026: yyy'))
|
||||
assert.ok(lang.endsWith(`y…${schluss}`))
|
||||
})
|
||||
|
||||
test('aufwandPositionen: zu viele Tage -> die ältesten Wochen werden zu KW-Zeilen (ISO-Woche, älteste zuerst), Einzeltage bleiben Tageszeilen', () => {
|
||||
// KW 41: 5., 6., 7.10.; KW 42: 12., 13., 14.10.; KW 43: 19., 20.10.; je 90 Minuten
|
||||
const tage = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-12', '2026-10-13', '2026-10-14', '2026-10-19', '2026-10-20']
|
||||
const blaetter = tage.map((tag, i) => blatt(`w${i + 1}`, tag, 90, 'ABCDEFGH'[i]))
|
||||
const lauf = (maxZeilen) => aufwandPositionen(blaetter, { satzCents: 10000, maxZeilen })
|
||||
assert.equal(lauf(8).positionen.length, 8)
|
||||
assert.deepEqual(lauf(6).positionen.map((p) => p.schluessel), ['KW-2026-41', '2026-10-12', '2026-10-13', '2026-10-14', '2026-10-19', '2026-10-20'])
|
||||
assert.deepEqual(lauf(6).positionen[0], {
|
||||
schluessel: 'KW-2026-41', beschreibung: `KW 41/2026: A; B; C – 4,5 Std. à 100,00${NBSP}€`, menge: 1, einzelpreisCents: 45000, betragCents: 45000,
|
||||
})
|
||||
assert.deepEqual(lauf(5).positionen.map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', '2026-10-19', '2026-10-20'])
|
||||
assert.deepEqual(lauf(3).positionen.map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', 'KW-2026-43'])
|
||||
assert.equal(lauf(3).positionen[2].beschreibung, `KW 43/2026: G; H – 3 Std. à 100,00${NBSP}€`)
|
||||
for (const n of [8, 6, 5, 3]) assert.equal(lauf(n).positionen.reduce((s, p) => s + p.betragCents, 0), 8 * 15000, `Summe bei ${n} Zeilen`)
|
||||
assert.deepEqual([lauf(3).von, lauf(3).bis, lauf(3).minuten], ['2026-10-05', '2026-10-20', 720])
|
||||
wirft(() => lauf(2), 400, 'Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als 2 Zeilen). Bitte weniger Arbeitsblätter auswählen.')
|
||||
// eine Woche mit nur einem Tag wird nicht zusammengefasst (es spart keine Zeile)
|
||||
const einzel = [blatt('a', '2026-10-06', 60, 'A'), blatt('b', '2026-10-12', 60, 'B'), blatt('c', '2026-10-13', 60, 'C'), blatt('d', '2026-10-14', 60, 'D')]
|
||||
assert.deepEqual(aufwandPositionen(einzel, { satzCents: 10000, maxZeilen: 2 }).positionen.map((p) => p.schluessel), ['2026-10-06', 'KW-2026-42'])
|
||||
})
|
||||
|
||||
test('aufwandPositionen: Standard 20 Zeilen, zeilentexte auch für KW-Zeilen, Woche 53 über den Jahreswechsel, mehr als 20 Wochen -> 400', () => {
|
||||
const blaetter = werktage('2026-10-05', 25).map((tag, i) => blatt(`w${String(i + 1).padStart(2, '0')}`, tag, 60, `T${i + 1}`)) // KW 41 bis 45
|
||||
const erg = aufwandPositionen(blaetter, { satzCents: 10000, zeilentexte: { 'KW-2026-42': 'KW 42/2026: Relaunch Teil 2' } })
|
||||
assert.equal(erg.positionen.length, 17)
|
||||
assert.deepEqual(erg.positionen.slice(0, 3).map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', '2026-10-19'])
|
||||
assert.equal(erg.positionen[1].beschreibung, `KW 42/2026: Relaunch Teil 2 – 5 Std. à 100,00${NBSP}€`)
|
||||
assert.equal(erg.minuten, 1500)
|
||||
|
||||
const wechsel = ['2026-12-30', '2026-12-31', '2027-01-01', '2027-01-04'].map((tag, i) => blatt(`j${i}`, tag, 60, `J${i}`))
|
||||
const zwei = aufwandPositionen(wechsel, { satzCents: 10000, maxZeilen: 2 })
|
||||
assert.deepEqual(zwei.positionen.map((p) => p.schluessel), ['KW-2026-53', '2027-01-04'])
|
||||
assert.equal(zwei.positionen[0].beschreibung, `KW 53/2026: J0; J1; J2 – 3 Std. à 100,00${NBSP}€`)
|
||||
|
||||
const montage = Array.from({ length: 21 }, (_, i) => new Date(Date.UTC(2026, 0, 5 + 7 * i)).toISOString().slice(0, 10))
|
||||
const wochen = montage.map((tag, i) => blatt(`m${i + 1}`, tag, 60, 'x'))
|
||||
wirft(() => aufwandPositionen(wochen, { satzCents: 10000 }), 400, 'Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als 20 Zeilen). Bitte weniger Arbeitsblätter auswählen.')
|
||||
assert.equal(aufwandPositionen(wochen.slice(0, 20), { satzCents: 10000 }).positionen.length, 20)
|
||||
})
|
||||
|
||||
test('aufwandPositionen: ungültiger Satz, leere Auswahl, kaputte Blätter', () => {
|
||||
const eins = [blatt('w1', '2026-10-06', 60, 'A')]
|
||||
for (const satzCents of [0, -1, 1.5, '10000', 10_000_001, undefined]) wirft(() => aufwandPositionen(eins, { satzCents }), 400, 'Der Stundensatz ist ungültig.')
|
||||
assert.deepEqual(aufwandPositionen([], { satzCents: 10000 }), { positionen: [], minuten: 0, von: null, bis: null })
|
||||
for (const kaputt of [{ datum: '06.10.2026' }, { datum: '2026-13-01' }, { minuten: 0 }, { minuten: 7.5 }, { minuten: '60' }]) {
|
||||
wirft(() => aufwandPositionen([blatt('w1', '2026-10-06', 60, 'A', kaputt)], { satzCents: 10000 }), 400, 'Ein Arbeitsblatt hat kein gültiges Datum oder keine Zeit.')
|
||||
}
|
||||
})
|
||||
|
||||
const AB = {
|
||||
id: 'a1', nummer: 'AB-2026-0002', titel: 'Website anelia-wolf.com', art: 'festpreis', status: 'versendet',
|
||||
gesendetAm: '2026-10-06T08:15:00.000+00:00', auftragVom: '2026-10-05', summeCents: 80000,
|
||||
positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 60000 }, { beschreibung: 'Texte', menge: 2, einzelpreisCents: 10000 }],
|
||||
zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' },
|
||||
}
|
||||
const ANZ_KOPF = 'gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026 (Website anelia-wolf.com)'
|
||||
|
||||
test('auftragDatum: Berliner Tag des Sendens, sonst der Tag der Absprache', () => {
|
||||
assert.equal(auftragDatum(AB), '2026-10-06')
|
||||
assert.equal(auftragDatum({ ...AB, gesendetAm: '2026-10-06T22:30:00.000+00:00' }), '2026-10-07')
|
||||
assert.equal(auftragDatum({ ...AB, gesendetAm: null }), '2026-10-05')
|
||||
})
|
||||
|
||||
test('anzahlungPosition: ohne Angaben der Prozentsatz aus dem Zahlungsplan (sonst 50 %), Text wie in der Spec', () => {
|
||||
assert.deepEqual(anzahlungPosition(AB, {}, 0), {
|
||||
schluessel: 'anzahlung', beschreibung: `Anzahlung 50 % ${ANZ_KOPF}`, menge: 1, einzelpreisCents: 40000, betragCents: 40000,
|
||||
})
|
||||
assert.equal(anzahlungPosition({ ...AB, zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } }, {}, 0).betragCents, 40000)
|
||||
assert.equal(anzahlungPosition({ ...AB, zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' } }, undefined, 0).betragCents, 24000)
|
||||
})
|
||||
|
||||
test('anzahlungPosition: Prozent oder Betrag, Bezeichnung änderbar, Text je Variante, lange Titel gekürzt', () => {
|
||||
assert.equal(anzahlungPosition(AB, { prozent: 30 }, 0).betragCents, 24000)
|
||||
assert.equal(anzahlungPosition(AB, { prozent: 100 }, 0).betragCents, 80000)
|
||||
const teil = anzahlungPosition(AB, { prozent: 25, bezeichnung: ' Teilrechnung ' }, 0)
|
||||
assert.deepEqual([teil.beschreibung, teil.betragCents], [`Teilrechnung 25 % ${ANZ_KOPF}`, 20000])
|
||||
const betrag = anzahlungPosition(AB, { betragCents: 25000 }, 0)
|
||||
assert.deepEqual([betrag.beschreibung, betrag.betragCents], [`Anzahlung ${ANZ_KOPF}`, 25000])
|
||||
const komma = anzahlungPosition(AB, { prozent: 33.3 }, 0)
|
||||
assert.deepEqual([komma.beschreibung, komma.betragCents], [`Anzahlung 33,3 % ${ANZ_KOPF}`, 26640])
|
||||
const lang = anzahlungPosition({ ...AB, titel: 'T'.repeat(300) }, { prozent: 50 }, 0).beschreibung
|
||||
assert.equal(lang.length, 200)
|
||||
assert.ok(lang.startsWith('Anzahlung 50 % gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026 (TTT'))
|
||||
assert.ok(lang.endsWith('T…)'))
|
||||
assert.equal(anzahlungPosition({ ...AB, titel: '' }, { prozent: 50 }, 0).beschreibung, 'Anzahlung 50 % gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026')
|
||||
})
|
||||
|
||||
test('anzahlungPosition: höchstens Summe minus bereits Berechnetes, nichts mehr offen -> 409', () => {
|
||||
assert.equal(anzahlungPosition(AB, {}, 40000).betragCents, 40000)
|
||||
wirft(() => anzahlungPosition(AB, {}, 50000), 400, `Die Anzahlung darf höchstens 300,00${NBSP}€ betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
|
||||
wirft(() => anzahlungPosition(AB, { betragCents: 80001 }, 0), 400, `Die Anzahlung darf höchstens 800,00${NBSP}€ betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
|
||||
wirft(() => anzahlungPosition(AB, {}, 80000), 409, 'Nichts mehr offen.')
|
||||
wirft(() => anzahlungPosition(AB, {}, 90000), 409, 'Nichts mehr offen.')
|
||||
})
|
||||
|
||||
test('anzahlungPosition: ungültige Angaben', () => {
|
||||
wirft(() => anzahlungPosition(AB, { prozent: 30, betragCents: 100 }, 0), 400, 'Anzahlung entweder in Prozent oder als Betrag angeben.')
|
||||
for (const prozent of [0, -5, 100.5, Number.NaN, '50', Infinity]) wirft(() => anzahlungPosition(AB, { prozent }, 0), 400, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.')
|
||||
for (const betragCents of [0, -5, 12.5, '100', Number.NaN]) wirft(() => anzahlungPosition(AB, { betragCents }, 0), 400, 'Der Betrag muss eine ganze Zahl in Cent ab 1 sein.')
|
||||
wirft(() => anzahlungPosition(AB, { prozent: 0.0001 }, 0), 400, 'Der Betrag muss mindestens 0,01 € betragen.')
|
||||
for (const bezeichnung of ['', ' ', 'B'.repeat(61), 7]) wirft(() => anzahlungPosition(AB, { bezeichnung }, 0), 400, 'Die Bezeichnung braucht 1 bis 60 Zeichen.')
|
||||
})
|
||||
|
||||
const FRUEH7 = { invoiceId: 'in_7', rechnungsnummer: 'WEBKLAR-0007', datum: '2026-10-08', betragCents: 40000, art: 'anzahlung' }
|
||||
|
||||
test('schlussPositionen: alle AB-Positionen, Zusatz, dann je frühere Rechnung eine Minuszeile (negativer Betrag, Menge 1)', () => {
|
||||
assert.deepEqual(schlussPositionen(AB, [FRUEH7], [{ description: 'Domain-Registrierung', quantity: 1, unitAmountCents: 1500 }]), [
|
||||
{ schluessel: 'ab-1', beschreibung: 'Website', menge: 1, einzelpreisCents: 60000, betragCents: 60000 },
|
||||
{ schluessel: 'ab-2', beschreibung: 'Texte', menge: 2, einzelpreisCents: 10000, betragCents: 20000 },
|
||||
{ schluessel: 'zusatz-1', beschreibung: 'Domain-Registrierung', menge: 1, einzelpreisCents: 1500, betragCents: 1500 },
|
||||
{ schluessel: 'abzug-in_7', beschreibung: 'abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026', menge: 1, einzelpreisCents: -40000, betragCents: -40000 },
|
||||
])
|
||||
})
|
||||
|
||||
test('schlussPositionen: ohne frühere Rechnungen nur AB-Positionen; Teilrechnung für andere Arten; Abzüge nach Datum; Summe 0 ohne Zeile', () => {
|
||||
assert.deepEqual(schlussPositionen(AB, [], []).map((p) => p.schluessel), ['ab-1', 'ab-2'])
|
||||
assert.deepEqual(schlussPositionen(AB, []).map((p) => p.schluessel), ['ab-1', 'ab-2'])
|
||||
const spaeter = { invoiceId: 'in_9', rechnungsnummer: 'WEBKLAR-0009', datum: '2026-10-12', betragCents: 10000, art: 'festbetrag' }
|
||||
const ohneBetrag = { ...FRUEH7, invoiceId: 'in_0', betragCents: 0 }
|
||||
assert.deepEqual(schlussPositionen(AB, [spaeter, ohneBetrag, FRUEH7]).slice(2).map((p) => [p.schluessel, p.beschreibung, p.betragCents]), [
|
||||
['abzug-in_7', 'abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026', -40000],
|
||||
['abzug-in_9', 'abzüglich Teilrechnung, Rechnung WEBKLAR-0009 vom 12.10.2026', -10000],
|
||||
])
|
||||
})
|
||||
|
||||
test('schlussPositionen: Summe nie 0 oder darunter (409), nie über 99.999.999 (400)', () => {
|
||||
wirft(() => schlussPositionen(AB, [{ ...FRUEH7, betragCents: 80000 }]), 409, 'Nichts mehr offen.')
|
||||
wirft(() => schlussPositionen(AB, [{ ...FRUEH7, betragCents: 90000 }]), 409, 'Nichts mehr offen.')
|
||||
assert.equal(schlussPositionen(AB, [{ ...FRUEH7, betragCents: 79999 }]).reduce((s, p) => s + p.betragCents, 0), 1)
|
||||
const gross = { ...AB, positionen: Array.from({ length: 10 }, (_, i) => ({ beschreibung: `P${i}`, menge: 1, einzelpreisCents: 10_000_000 })) }
|
||||
wirft(() => schlussPositionen(gross, []), 400, 'Rechnungssumme zu hoch.')
|
||||
})
|
||||
|
||||
test('schlussPositionen: bis zu 40 Zeilen (20 AB-Positionen, 10 Nachträge, 10 Abzüge), jede Gruppe mit eigener Grenze und Meldung', () => {
|
||||
assert.deepEqual(SCHLUSS_GRENZEN, { abPositionen: 20, zusatz: 10, abzuege: 10, zeilen: 40 })
|
||||
const abMit = (n) => ({ ...AB, summeCents: n * 1000, positionen: Array.from({ length: n }, (_, i) => ({ beschreibung: `Pos ${i + 1}`, menge: 1, einzelpreisCents: 1000 })) })
|
||||
const abzug = (n) => ({ invoiceId: `in_k${String(n).padStart(2, '0')}`, rechnungsnummer: `WK-${String(n).padStart(4, '0')}`, datum: '2026-10-08', betragCents: 100, art: 'anzahlung' })
|
||||
const abzuege = (n) => Array.from({ length: n }, (_, i) => abzug(i + 1))
|
||||
const nachtraege = (n) => Array.from({ length: n }, (_, i) => ({ description: `Nachtrag ${i + 1}`, quantity: 1, unitAmountCents: 500 }))
|
||||
// 20 AB-Positionen und ein Abzug: gültig (21 Zeilen)
|
||||
assert.equal(schlussPositionen(abMit(20), abzuege(1)).length, 21)
|
||||
// volle Ausbaustufe: 20 + 10 + 10 = 40 Zeilen, Reihenfolge AB, Nachträge, Abzüge
|
||||
const voll = schlussPositionen(abMit(20), abzuege(10), nachtraege(10))
|
||||
assert.equal(voll.length, 40)
|
||||
assert.deepEqual([0, 19, 20, 29, 30, 39].map((i) => voll[i].schluessel), ['ab-1', 'ab-20', 'zusatz-1', 'zusatz-10', 'abzug-in_k01', 'abzug-in_k10'])
|
||||
assert.equal(voll.reduce((s, p) => s + p.betragCents, 0), 20000 + 5000 - 1000)
|
||||
// jede Gruppe über ihrer Grenze: 400 mit eigener Meldung
|
||||
wirft(() => schlussPositionen(abMit(20), abzuege(11)), 400, 'Die Schlussrechnung hätte 11 Abzüge für frühere Rechnungen; erlaubt sind höchstens 10.')
|
||||
wirft(() => schlussPositionen(abMit(20), [], nachtraege(11)), 400, 'Eine Schlussrechnung erlaubt höchstens 10 Zusatzpositionen.')
|
||||
wirft(() => schlussPositionen(abMit(21), []), 400, 'Die Auftragsbestätigung hat 21 Positionen; eine Schlussrechnung übernimmt höchstens 20.')
|
||||
// andere Eingaben für die Zusatzpositionen prüft zusatzPositionen wie sonst
|
||||
wirft(() => schlussPositionen(AB, [], 'Domain'), 400, ANZAHL)
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
// Gemeinsame Testdaten für die Rechnungstests des Strangs L2. Erst nach useTestEnv() importieren (zieht config.js).
|
||||
import { config } from '../server/config.js'
|
||||
import { deleteDocument } from '../server/services/appwriteAdmin.js'
|
||||
import { createAuftrag, updateAuftrag } from '../server/services/auftraege/auftragStore.js'
|
||||
import { createProjektRechnung } from '../server/services/auftraege/projektRechnungenStore.js'
|
||||
|
||||
/** n Arbeitstage (Montag bis Freitag) ab start (YYYY-MM-DD, einschließlich). */
|
||||
export function werktage(start, n) {
|
||||
const tage = []
|
||||
for (let ms = Date.parse(`${start}T00:00:00Z`); tage.length < n; ms += 86_400_000) {
|
||||
const tag = new Date(ms)
|
||||
if (tag.getUTCDay() % 6 !== 0) tage.push(tag.toISOString().slice(0, 10))
|
||||
}
|
||||
return tage
|
||||
}
|
||||
|
||||
const deutsch = (tag) => tag.split('-').reverse().join('.')
|
||||
|
||||
/**
|
||||
* Kunden k1 (fest, mit Anschrift: Frau Wolf), k2 (fest), lead1 (Interessent); Projekte p1 (k1, Ticket t1), p2 (k2, Ticket t2),
|
||||
* p3 (k1, Ticket t4); Tickets t1 bis t4 (t2 gehört k2); Arbeitsblätter ws1 bis wsT3 (Oktober) und wl01 bis wl20 (Großprojekt).
|
||||
*/
|
||||
export function seedRechnungsDaten(aw) {
|
||||
const kunde = (id, over = {}) =>
|
||||
aw.seed('customers', { $id: id, email: `${id}@example.invalid`, name: `Kunde ${id}`, customerStatus: 'active', status: 'active', street: 'Hauptstr. 1', postalCode: '10115', city: 'Berlin', ...over })
|
||||
kunde('k1', { name: 'Anelia Wolf', email: 'wolf@example.invalid' })
|
||||
kunde('k2')
|
||||
kunde('lead1', { customerStatus: 'lead' })
|
||||
const projekt = (id, customerId, projectName, ticketId) => aw.seed('websiteProjects', { $id: id, customerId, projectName, ticketId })
|
||||
projekt('p1', 'k1', 'Website anelia-wolf.com', 't1')
|
||||
projekt('p2', 'k2', 'Fremdes Projekt', 't2')
|
||||
projekt('p3', 'k1', 'Großprojekt', 't4')
|
||||
for (const [id, customerId, woid] of [['t1', 'k1', 'WO-100'], ['t2', 'k2', 'WO-200'], ['t3', 'k1', 'WO-101'], ['t4', 'k1', 'WO-102']]) {
|
||||
aw.seed('workorders', { $id: id, customerId, woid, topic: `Ticket ${woid}`, status: 'occupied' })
|
||||
}
|
||||
const blatt = (id, workorderId, startDate, totalTime, details, over = {}) =>
|
||||
aw.seed('worksheets', { $id: id, workorderId, woid: 'WO', startDate, totalTime, details, serviceType: 'remote', countsToPlan: false, isComment: false, ...over })
|
||||
blatt('ws1', 't1', '06.10.2026', 90, 'Startseite')
|
||||
blatt('ws2', 't1', '06.10.2026', 45, 'Kontaktformular')
|
||||
blatt('ws3', 't1', '07.10.2026', 60, 'Impressum')
|
||||
blatt('wsAbo', 't1', '07.10.2026', 60, 'Wartung', { countsToPlan: true })
|
||||
blatt('wsKomm', 't1', '07.10.2026', 30, 'Notiz', { isComment: true })
|
||||
blatt('wsNull', 't1', '07.10.2026', 0, 'Leer')
|
||||
blatt('wsFremd', 't2', '07.10.2026', 60, 'Fremd')
|
||||
blatt('wsT3', 't3', '08.10.2026', 30, 'Zusatzticket')
|
||||
// Großprojekt: 20 Arbeitstage (vier Wochen, KW 45 bis 48) zu je 60 Minuten
|
||||
werktage('2026-11-02', 20).forEach((tag, i) => blatt(`wl${String(i + 1).padStart(2, '0')}`, 't4', deutsch(tag), 60, `Etappe ${i + 1}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine versendete Festpreis-AB des Kunden k1 zu p1 (80.000 Cent: Website 600 €, Texte 2 × 100 €; Anzahlung 50 %; AB-2026-0002,
|
||||
* gesendet am 06.10.2026) über die L0-Speicher anlegen. over gilt für createAuftrag, patch für das anschließende updateAuftrag
|
||||
* (Status, Nummer, bestaetigtAm, …).
|
||||
*/
|
||||
export async function legeAuftragAn(over = {}, patch = {}) {
|
||||
const roh = await createAuftrag({
|
||||
kundenId: 'k1', projektId: 'p1', titel: 'Website anelia-wolf.com', zeitquelle: [], art: 'festpreis',
|
||||
positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 60000 }, { beschreibung: 'Texte', menge: 2, einzelpreisCents: 10000 }],
|
||||
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
|
||||
zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14,
|
||||
auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '', erstelltVon: 'kenso@webklar.com',
|
||||
...over,
|
||||
})
|
||||
return updateAuftrag(roh.id, { status: 'versendet', nummer: 'AB-2026-0002', jahr: 2026, laufnummer: 2, gesendetAm: '2026-10-06T08:15:00.000Z', ...patch })
|
||||
}
|
||||
|
||||
const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000)
|
||||
|
||||
/**
|
||||
* Eine Projektrechnung wie nach createProjectInvoice: Stripe-Rechnung im Nachbau (status draft, open, paid oder void; ohneStripe: die
|
||||
* Rechnung gibt es bei Stripe nicht, z. B. im Dashboard gelöscht) plus aktiver Projektdatensatz. docNummer ist die Nummer im Datensatz
|
||||
* (Vorgabe: die der Rechnung, bei einem Entwurf leer).
|
||||
*/
|
||||
export async function legeRechnungAn(st, id, {
|
||||
status = 'draft', nummer = 'WEBKLAR-0007', total = 40000, art = 'aufwand', auftragId = '', projektId = 'p1',
|
||||
arbeitsblattIds = [], finalisiert = '2026-10-08T10:00:00.000Z', ohneStripe = false, docNummer,
|
||||
} = {}) {
|
||||
const entwurf = status === 'draft'
|
||||
if (!ohneStripe) {
|
||||
st.invoices.set(id, {
|
||||
id, object: 'invoice', number: entwurf ? null : nummer, status, customer: 'cus_x', created: unix(finalisiert), total, amount_due: total,
|
||||
amount_paid: status === 'paid' ? total : 0, amount_remaining: status === 'paid' ? 0 : total,
|
||||
status_transitions: { finalized_at: entwurf ? null : unix(finalisiert) }, metadata: { art }, lines: { object: 'list', data: [], has_more: false },
|
||||
})
|
||||
}
|
||||
await createProjektRechnung(id, {
|
||||
kundenId: 'k1', projektId, auftragId, art, betragCents: total, arbeitsblattIds,
|
||||
rechnungsnummer: docNummer ?? (entwurf ? '' : nummer), aktiv: true, erstelltVon: 'k', erstelltAm: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
/** Aufträge und Projektdatensätze löschen, Stripe-Nachbau (Rechnungen, Kunden, Aufrufe) leeren. */
|
||||
export async function leereRechnungsDaten(aw, st) {
|
||||
for (const sammlung of [config.collections.auftraege, config.collections.projektRechnungen]) {
|
||||
for (const id of aw.docs(sammlung).keys()) await deleteDocument(sammlung, id)
|
||||
}
|
||||
st.invoices.clear()
|
||||
st.customers.clear()
|
||||
st.calls.length = 0
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
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 { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
|
||||
|
||||
// Nachbau von Appwrite mit dem Schema und den Datumsfeldern der neuen Collections und dem Standard-Limit 25 (wie in den Tests von L0)
|
||||
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, legeRechnungAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js')
|
||||
const { baueRechnung } = await import('../server/services/auftraege/rechnungsbau.js')
|
||||
const { getProjektRechnung, updateProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js')
|
||||
const { getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
|
||||
after(() => Promise.all([aw.close(), st.close()]))
|
||||
seedRechnungsDaten(aw)
|
||||
config.auftraege.mode = 'on'
|
||||
beforeEach(() => leereRechnungsDaten(aw, st))
|
||||
|
||||
const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt
|
||||
const SCHALTER_AUS = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.'
|
||||
const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }]
|
||||
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('baueRechnung frei: freie Positionen, nur Kopf-Metadaten, keine Zusatzfelder, kein Projektdatensatz', async () => {
|
||||
const e = await baueRechnung({ customerId: 'k1', items: POSTEN, createdBy: 'kenso@webklar.com' })
|
||||
assert.equal(e.art, 'frei')
|
||||
assert.equal(e.kunde.$id, 'k1')
|
||||
assert.equal(e.projekt, null)
|
||||
assert.equal(e.auftrag, null)
|
||||
assert.deepEqual(e.positionen, [{ schluessel: 'pos-1', beschreibung: 'Domain', menge: 1, einzelpreisCents: 1200, betragCents: 1200 }])
|
||||
assert.equal(e.summeCents, 1200)
|
||||
assert.equal(e.leistungszeitraum, '')
|
||||
assert.deepEqual(e.customFields, [])
|
||||
assert.deepEqual(e.metadata, { webklarCustomerId: 'k1', quelle: 'woms', erstelltVon: 'kenso@webklar.com', art: 'frei' })
|
||||
assert.equal(e.projektRechnung, null)
|
||||
assert.equal((await baueRechnung({ customerId: 'k1', items: POSTEN })).metadata.erstelltVon, '')
|
||||
})
|
||||
|
||||
test('baueRechnung frei: Projekt, Auftrag, Arbeitsblätter und Anzahlung der Eingabe bleiben ohne Wirkung', async () => {
|
||||
const e = await baueRechnung({ customerId: 'k1', art: 'frei', items: POSTEN, projektId: 'p1', auftragId: 'a1', arbeitsblattIds: ['ws1'], anzahlung: { prozent: 10 } })
|
||||
assert.equal(e.projekt, null)
|
||||
assert.equal(e.auftrag, null)
|
||||
assert.equal(e.projektRechnung, null)
|
||||
assert.deepEqual(e.customFields, [])
|
||||
assert.deepEqual(Object.keys(e.metadata).sort(), ['art', 'erstelltVon', 'quelle', 'webklarCustomerId'])
|
||||
})
|
||||
|
||||
test('baueRechnung: Leistungszeitraum ist optional, wird getrimmt und hat höchstens 140 Zeichen', async () => {
|
||||
const e = await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: ' Oktober 2026 ' })
|
||||
assert.equal(e.leistungszeitraum, 'Oktober 2026')
|
||||
assert.deepEqual(e.customFields, [{ name: 'Leistungszeitraum', value: 'Oktober 2026' }])
|
||||
assert.equal((await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: 'z'.repeat(140) })).leistungszeitraum.length, 140)
|
||||
for (const leer of [undefined, null, '', ' ']) assert.deepEqual((await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: leer })).customFields, [])
|
||||
const MELDUNG = 'Der Leistungszeitraum muss ein Text mit höchstens 140 Zeichen sein.'
|
||||
for (const leistungszeitraum of ['z'.repeat(141), 5, {}]) await wirft(baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum }), 400, MELDUNG)
|
||||
})
|
||||
|
||||
test('baueRechnung: unbekannte Art, unbekannter oder nicht fester Kunde, ungültige Positionen -> 400 ohne Stripe-Aufruf', async () => {
|
||||
for (const art of ['quatsch', 'FREI', 5, {}]) await wirft(baueRechnung({ customerId: 'k1', art, items: POSTEN }), 400, 'Unbekannte Rechnungsart.')
|
||||
for (const customerId of [undefined, '', 'gibtEsNicht', '../x', 5]) await wirft(baueRechnung({ customerId, items: POSTEN }), 400, 'Kunde nicht gefunden.')
|
||||
await wirft(baueRechnung({ customerId: 'lead1', items: POSTEN }), 400, 'Rechnungen nur für feste Kunden.')
|
||||
await wirft(baueRechnung({ customerId: 'k1' }), 400, 'Eine Rechnung braucht 1 bis 20 Positionen.')
|
||||
assert.equal(st.calls.length, 0)
|
||||
})
|
||||
|
||||
test('baueRechnung festbetrag: Projekt, Zusatzfeld Projekt, Metadaten und Projektdatensatz', async () => {
|
||||
const items = [{ description: 'Erstellung der Website anelia-wolf.com, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }]
|
||||
const e = await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items, createdBy: 'kenso@webklar.com' })
|
||||
assert.deepEqual(e.projekt, { id: 'p1', name: 'Website anelia-wolf.com', ticketId: 't1' })
|
||||
assert.equal(e.auftrag, null)
|
||||
assert.equal(e.summeCents, 50000)
|
||||
assert.deepEqual(e.customFields, [{ name: 'Projekt', value: 'Website anelia-wolf.com' }])
|
||||
assert.deepEqual(e.metadata, { webklarCustomerId: 'k1', quelle: 'woms', erstelltVon: 'kenso@webklar.com', art: 'festbetrag', projektId: 'p1', projektName: 'Website anelia-wolf.com' })
|
||||
const { erstelltAm, ...rest } = e.projektRechnung
|
||||
assert.match(erstelltAm, /^\d{4}-\d{2}-\d{2}T/)
|
||||
assert.deepEqual(rest, { kundenId: 'k1', projektId: 'p1', auftragId: '', art: 'festbetrag', betragCents: 50000, arbeitsblattIds: [], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com' })
|
||||
// eine genannte auftragId bleibt bei dieser Art ohne Wirkung
|
||||
assert.equal((await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', auftragId: 'a1', items })).auftrag, null)
|
||||
})
|
||||
|
||||
test('baueRechnung festbetrag: lange Projektnamen werden auf 140 Zeichen gekürzt, leere ersetzt', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'plang', customerId: 'k1', projectName: 'P'.repeat(200), ticketId: '' })
|
||||
aw.seed('websiteProjects', { $id: 'psub', customerId: 'k1', projectName: '', subdomain: 'wolf-test', ticketId: '' })
|
||||
aw.seed('websiteProjects', { $id: 'pleer', customerId: 'k1', ticketId: '' })
|
||||
const e = await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'plang', items: POSTEN })
|
||||
assert.equal(e.customFields[0].value.length, 140)
|
||||
assert.ok(e.customFields[0].value.endsWith('P…'))
|
||||
assert.equal(e.metadata.projektName, e.customFields[0].value)
|
||||
const name = async (projektId) => (await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId, items: POSTEN })).customFields[0].value
|
||||
assert.equal(await name('psub'), 'wolf-test')
|
||||
assert.equal(await name('pleer'), 'Projekt')
|
||||
})
|
||||
|
||||
test('baueRechnung festbetrag: Projekt ist Pflicht, fremde und unbekannte Projekte sind 404 (kein Unterschied), ungültige Positionen 400', async () => {
|
||||
const mit = (projektId, items = POSTEN) => baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId, items })
|
||||
for (const projektId of [undefined, '', null, 5]) await wirft(mit(projektId), 400, 'Für diese Rechnungsart ist ein Projekt nötig.')
|
||||
for (const projektId of ['gibtEsNicht', 'p2', '../x']) await wirft(mit(projektId), 404, 'Projekt nicht gefunden.')
|
||||
await wirft(mit('p1', []), 400, 'Eine Rechnung braucht 1 bis 20 Positionen.')
|
||||
assert.equal(st.calls.length, 0)
|
||||
})
|
||||
|
||||
const eingabe = (over = {}) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', createdBy: 'kenso@webklar.com', ...over })
|
||||
const aufwandAb = (patch = {}, over = {}) =>
|
||||
legeAuftragAn(
|
||||
{ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 12000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, zeitquelle: ['t1', 't3'], ...over },
|
||||
{ nummer: 'AB-2026-0005', laufnummer: 5, status: 'bestaetigt', bestaetigtAm: '2026-10-07T09:00:00.000Z', ...patch }
|
||||
)
|
||||
|
||||
test('baueRechnung aufwand: alle offenen Blätter des Projekttickets, eine Zeile je Tag, Satz 100 €, Zeitraum, Zusatzfeld', async () => {
|
||||
const e = await baueRechnung(eingabe())
|
||||
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['2026-10-06', 22500], ['2026-10-07', 10000]])
|
||||
assert.equal(e.positionen[0].beschreibung, `06.10.2026: Startseite; Kontaktformular – 2,25 Std. à 100,00${NBSP}€`)
|
||||
assert.equal(e.positionen[1].beschreibung, `07.10.2026: Impressum – 1 Std. à 100,00${NBSP}€`)
|
||||
assert.equal(e.summeCents, 32500)
|
||||
assert.equal(e.leistungszeitraum, '06.10.–07.10.2026')
|
||||
assert.deepEqual(e.customFields, [{ name: 'Projekt', value: 'Website anelia-wolf.com' }, { name: 'Leistungszeitraum', value: '06.10.–07.10.2026' }])
|
||||
assert.deepEqual(e.projekt, { id: 'p1', name: 'Website anelia-wolf.com', ticketId: 't1' })
|
||||
assert.equal(e.auftrag, null)
|
||||
assert.equal(e.metadata.art, 'aufwand')
|
||||
assert.equal(e.metadata.projektId, 'p1')
|
||||
const { erstelltAm, ...rest } = e.projektRechnung
|
||||
assert.ok(erstelltAm)
|
||||
assert.deepEqual(rest, { kundenId: 'k1', projektId: 'p1', auftragId: '', art: 'aufwand', betragCents: 32500, arbeitsblattIds: ['ws1', 'ws2', 'ws3'], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com' })
|
||||
assert.equal(st.calls.length, 0, 'Aufwand liest nichts bei Stripe')
|
||||
})
|
||||
|
||||
test('baueRechnung aufwand: ausgewählte Blätter (auch von einem anderen Ticket des Kunden), Zusatzposition, eigener Zeitraum und Zeilentext', async () => {
|
||||
const e = await baueRechnung(eingabe({
|
||||
arbeitsblattIds: ['ws2', 'wsT3', 'ws2'], items: POSTEN, leistungszeitraum: ' Oktober 2026 ',
|
||||
zeilentexte: { '2026-10-08': '08.10.2026: Zusatzticket Impressum' },
|
||||
}))
|
||||
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['2026-10-06', 7500], ['2026-10-08', 5000], ['zusatz-1', 1200]])
|
||||
assert.equal(e.positionen[1].beschreibung, `08.10.2026: Zusatzticket Impressum – 0,5 Std. à 100,00${NBSP}€`)
|
||||
assert.equal(e.summeCents, 13700)
|
||||
assert.equal(e.leistungszeitraum, 'Oktober 2026')
|
||||
assert.deepEqual(e.projektRechnung.arbeitsblattIds, ['ws2', 'wsT3'])
|
||||
})
|
||||
|
||||
test('baueRechnung aufwand: ungültige Auswahl -> 400 ohne Stripe-Aufruf (fremde, unbekannte, unabrechenbare, zu viele Blätter)', async () => {
|
||||
const mit = (arbeitsblattIds) => baueRechnung(eingabe({ arbeitsblattIds }))
|
||||
await wirft(mit([]), 400, 'Keine Arbeitsblätter ausgewählt.')
|
||||
for (const kaputt of ['ws1', [5], { 0: 'ws1' }]) await wirft(mit(kaputt), 400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.')
|
||||
for (const id of ['gibtEsNicht', 'wsFremd', '../x']) await wirft(mit(['ws1', id]), 400, 'Arbeitsblatt nicht gefunden.')
|
||||
for (const id of ['wsAbo', 'wsKomm', 'wsNull']) {
|
||||
await wirft(mit(['ws1', id]), 400, 'Mindestens ein Arbeitsblatt ist nicht abrechenbar (Abo-Zeit, Kommentar oder ohne Zeit).')
|
||||
}
|
||||
await wirft(mit(Array.from({ length: 401 }, (_, i) => `ws${i}`)), 400, 'Zu viele Arbeitsblätter ausgewählt (höchstens 400).')
|
||||
assert.equal(st.calls.length, 0)
|
||||
})
|
||||
|
||||
test('baueRechnung aufwand: bereits abgerechnete Blätter -> 409 mit der Nummer von Stripe oder Entwurf, stornierte geben frei, Standardauswahl nur Offenes', async () => {
|
||||
await legeRechnungAn(st, 'in_alt1', { status: 'open', nummer: 'WEBKLAR-0009', arbeitsblattIds: ['ws1'] })
|
||||
const mit = () => baueRechnung(eingabe({ arbeitsblattIds: ['ws1', 'ws3'] }))
|
||||
await wirft(mit(), 409, 'Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).')
|
||||
st.invoices.set('in_alt1', { ...st.invoices.get('in_alt1'), status: 'draft', number: null })
|
||||
await wirft(mit(), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
|
||||
assert.deepEqual((await baueRechnung(eingabe())).projektRechnung.arbeitsblattIds, ['ws2', 'ws3'])
|
||||
await updateProjektRechnung('in_alt1', { aktiv: false })
|
||||
assert.equal((await mit()).positionen.length, 2)
|
||||
await legeRechnungAn(st, 'in_alt2', { arbeitsblattIds: ['ws1', 'ws2', 'ws3'] })
|
||||
await wirft(baueRechnung(eingabe()), 409, 'Keine offenen Arbeitsblätter zum Abrechnen.')
|
||||
})
|
||||
|
||||
test('baueRechnung aufwand: Selbstheilung - eine im Stripe-Dashboard stornierte oder gelöschte Rechnung hält ihre Blätter nicht fest, eine fehlende Nummer wird nachgetragen', async () => {
|
||||
await legeRechnungAn(st, 'in_leer1', { status: 'void', nummer: 'WEBKLAR-0010', arbeitsblattIds: ['ws1'] })
|
||||
await legeRechnungAn(st, 'in_leer2', { ohneStripe: true, arbeitsblattIds: ['ws2'] })
|
||||
const e = await baueRechnung(eingabe({ arbeitsblattIds: ['ws1', 'ws2'] }))
|
||||
assert.deepEqual([e.positionen.length, e.summeCents], [1, 22500])
|
||||
for (const id of ['in_leer1', 'in_leer2']) assert.equal((await getProjektRechnung(id)).aktiv, false, id)
|
||||
// auch die Standardauswahl heilt, bevor sie "nichts offen" meldet
|
||||
await legeRechnungAn(st, 'in_leer3', { status: 'void', nummer: 'WEBKLAR-0012', arbeitsblattIds: ['ws1', 'ws2', 'ws3'] })
|
||||
assert.deepEqual((await baueRechnung(eingabe())).projektRechnung.arbeitsblattIds, ['ws1', 'ws2', 'ws3'])
|
||||
assert.equal((await getProjektRechnung('in_leer3')).aktiv, false)
|
||||
// bei Stripe finalisiert, im Datensatz fehlt die Nummer: die Meldung nennt die Nummer von Stripe, der Datensatz wird nachgetragen
|
||||
await legeRechnungAn(st, 'in_offen', { status: 'open', nummer: 'WEBKLAR-0011', docNummer: '', arbeitsblattIds: ['ws3'] })
|
||||
await wirft(baueRechnung(eingabe({ arbeitsblattIds: ['ws3'] })), 409, 'Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0011).')
|
||||
assert.equal((await getProjektRechnung('in_offen')).rechnungsnummer, 'WEBKLAR-0011')
|
||||
})
|
||||
|
||||
test('baueRechnung aufwand: Satz, Auftrag und Zeitquelle aus der jüngsten gültigen Aufwands-AB; ohne Schalter bleibt es bei 100 €', async () => {
|
||||
const ab = await aufwandAb()
|
||||
const e = await baueRechnung(eingabe())
|
||||
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['2026-10-06', 27000], ['2026-10-07', 12000], ['2026-10-08', 6000]])
|
||||
assert.ok(e.positionen[0].beschreibung.endsWith(` – 2,25 Std. à 120,00${NBSP}€`))
|
||||
assert.equal(e.leistungszeitraum, '06.10.–08.10.2026')
|
||||
assert.equal(e.auftrag.nummer, 'AB-2026-0005')
|
||||
assert.deepEqual(e.customFields.map((f) => f.name), ['Projekt', 'Auftrag', 'Leistungszeitraum'])
|
||||
assert.equal(e.customFields[1].value, 'AB-2026-0005 vom 06.10.2026')
|
||||
assert.deepEqual([e.metadata.auftragId, e.metadata.auftragNummer], [ab.id, 'AB-2026-0005'])
|
||||
assert.equal(e.projektRechnung.auftragId, ab.id)
|
||||
// ein neuerer, aber stornierter Aufwandsauftrag ändert nichts
|
||||
await aufwandAb({ nummer: 'AB-2026-0006', status: 'storniert' }, { stundensatzCents: 15000 })
|
||||
assert.equal((await baueRechnung(eingabe())).auftrag.nummer, 'AB-2026-0005')
|
||||
config.auftraege.mode = 'off'
|
||||
try {
|
||||
const ohne = await baueRechnung(eingabe({ arbeitsblattIds: ['ws1'] }))
|
||||
assert.equal(ohne.auftrag, null)
|
||||
assert.equal(ohne.positionen[0].betragCents, 15000) // 90 Minuten zu 100 €
|
||||
await wirft(baueRechnung(eingabe({ auftragId: ab.id })), 400, SCHALTER_AUS)
|
||||
} finally {
|
||||
config.auftraege.mode = 'on'
|
||||
}
|
||||
})
|
||||
|
||||
test('baueRechnung aufwand: ausdrücklich genannter Auftrag muss passen (Aufwand, eigener Kunde, gültig, gleiches Projekt)', async () => {
|
||||
const festpreis = await legeAuftragAn()
|
||||
const fremd = await legeAuftragAn({ kundenId: 'k2', projektId: 'p2' }, { nummer: 'AB-2026-0008' })
|
||||
const entwurf = await aufwandAb({ nummer: 'AB-2026-0009', status: 'entwurf' })
|
||||
const anderes = await aufwandAb({ nummer: 'AB-2026-0010' }, { projektId: 'p3' })
|
||||
const mit = (auftragId) => baueRechnung(eingabe({ auftragId }))
|
||||
await wirft(mit(festpreis.id), 400, 'Eine Aufwandsrechnung passt nur zu einer Aufwands-Auftragsbestätigung.')
|
||||
await wirft(mit(fremd.id), 404, 'Auftrag nicht gefunden.')
|
||||
await wirft(mit('gibtEsNicht'), 404, 'Auftrag nicht gefunden.')
|
||||
await wirft(mit(entwurf.id), 400, 'Die Auftragsbestätigung ist nicht mehr gültig.')
|
||||
await wirft(mit(anderes.id), 400, 'Der Auftrag gehört nicht zu diesem Projekt.')
|
||||
const gueltig = await aufwandAb({ nummer: 'AB-2026-0011' })
|
||||
assert.equal((await mit(gueltig.id)).auftrag.id, gueltig.id)
|
||||
})
|
||||
|
||||
test('baueRechnung aufwand: Zusatzpositionen zählen zu den 20 Zeilen, die ältesten Wochen werden zu KW-Zeilen', async () => {
|
||||
const ohne = await baueRechnung(eingabe({ projektId: 'p3' }))
|
||||
assert.equal(ohne.positionen.length, 20)
|
||||
assert.equal(ohne.positionen[0].schluessel, '2026-11-02')
|
||||
const mit = await baueRechnung(eingabe({ projektId: 'p3', items: POSTEN }))
|
||||
assert.equal(mit.positionen.length, 17)
|
||||
assert.deepEqual([mit.positionen[0].schluessel, mit.positionen[1].schluessel, mit.positionen.at(-1).schluessel], ['KW-2026-45', '2026-11-09', 'zusatz-1'])
|
||||
assert.equal(mit.summeCents, 20 * 10000 + 1200)
|
||||
assert.ok(mit.positionen.every((p) => p.beschreibung.length <= 200))
|
||||
})
|
||||
|
||||
test('baueRechnung aufwand: Projekt ist Pflicht, fremdes Projekt 404, Projekt ohne offene Blätter 409, Zeilentexte müssen ein Objekt mit Texten sein', async () => {
|
||||
for (const projektId of [undefined, '', null]) await wirft(baueRechnung(eingabe({ projektId })), 400, 'Für diese Rechnungsart ist ein Projekt nötig.')
|
||||
await wirft(baueRechnung(eingabe({ projektId: 'p2' })), 404, 'Projekt nicht gefunden.')
|
||||
aw.seed('websiteProjects', { $id: 'pohne', customerId: 'k1', projectName: 'Ohne Ticket', ticketId: '' })
|
||||
await wirft(baueRechnung(eingabe({ projektId: 'pohne' })), 409, 'Keine offenen Arbeitsblätter zum Abrechnen.')
|
||||
for (const zeilentexte of [['x'], 'x', 5, { '2026-10-06': 5 }]) {
|
||||
await wirft(baueRechnung(eingabe({ zeilentexte })), 400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.')
|
||||
}
|
||||
assert.equal((await baueRechnung(eingabe({ zeilentexte: null }))).positionen.length, 2)
|
||||
})
|
||||
|
||||
/** Frühere Rechnung zur AB (Art anzahlung, wenn nicht anders gesagt): Stripe-Rechnung im Nachbau plus aktiver Projektdatensatz. */
|
||||
const fruehereRechnung = (ab, { id, art = 'anzahlung', status = 'open', ...optionen }) => legeRechnungAn(st, id, { art, status, auftragId: ab.id, ...optionen })
|
||||
|
||||
test('baueRechnung anzahlung: Position aus dem Zahlungsplan, Zusatzfelder Projekt und Auftrag, Metadaten, Projektdatensatz', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
const e = await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, createdBy: 'kenso@webklar.com' })
|
||||
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.beschreibung, p.betragCents]), [
|
||||
['anzahlung', 'Anzahlung 50 % gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026 (Website anelia-wolf.com)', 40000],
|
||||
])
|
||||
assert.equal(e.summeCents, 40000)
|
||||
assert.deepEqual(e.customFields, [{ name: 'Projekt', value: 'Website anelia-wolf.com' }, { name: 'Auftrag', value: 'AB-2026-0002 vom 06.10.2026' }])
|
||||
assert.deepEqual(e.metadata, {
|
||||
webklarCustomerId: 'k1', quelle: 'woms', erstelltVon: 'kenso@webklar.com', art: 'anzahlung',
|
||||
projektId: 'p1', projektName: 'Website anelia-wolf.com', auftragId: ab.id, auftragNummer: 'AB-2026-0002',
|
||||
})
|
||||
assert.equal(e.auftrag.id, ab.id)
|
||||
assert.equal(e.projekt.id, 'p1')
|
||||
const { erstelltAm, ...rest } = e.projektRechnung
|
||||
assert.ok(erstelltAm)
|
||||
assert.deepEqual(rest, { kundenId: 'k1', projektId: 'p1', auftragId: ab.id, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com' })
|
||||
assert.equal(st.calls.length, 0, 'ohne frühere Rechnungen gibt es nichts bei Stripe zu lesen')
|
||||
})
|
||||
|
||||
test('baueRechnung anzahlung: Prozent oder Betrag änderbar; nur finalisierte Rechnungen mindern das Offene', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
const lauf = (anzahlung) => baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung })
|
||||
assert.ok((await lauf({ prozent: 25, bezeichnung: 'Teilrechnung' })).positionen[0].beschreibung.startsWith('Teilrechnung 25 % gemäß'))
|
||||
assert.equal((await lauf({ betragCents: 25000 })).summeCents, 25000)
|
||||
await fruehereRechnung(ab, { id: 'in_f1', status: 'open', total: 40000 })
|
||||
await fruehereRechnung(ab, { id: 'in_f2', status: 'void', total: 99000, nummer: 'WEBKLAR-0008' }) // storniert: zählt nicht
|
||||
await fruehereRechnung(ab, { id: 'in_f3', status: 'draft', total: 5000 }) // Entwurf einer Anzahlung: zählt nicht
|
||||
assert.equal((await lauf(undefined)).summeCents, 40000)
|
||||
assert.equal((await getProjektRechnung('in_f2')).aktiv, false, 'die im Dashboard stornierte Rechnung wurde freigegeben')
|
||||
await wirft(lauf({ betragCents: 40001 }), 400, `Die Anzahlung darf höchstens 400,00${NBSP}€ betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
|
||||
await fruehereRechnung(ab, { id: 'in_f4', status: 'paid', total: 40000, nummer: 'WEBKLAR-0009' })
|
||||
await wirft(lauf(undefined), 409, 'Nichts mehr offen.')
|
||||
for (const kaputt of ['viel', 5, ['x']]) await wirft(lauf(kaputt), 400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.')
|
||||
})
|
||||
|
||||
test('baueRechnung anzahlung: ein Entwurf der Schlussrechnung zur AB blockiert weitere Anzahlungen (409)', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
await fruehereRechnung(ab, { id: 'in_s1', status: 'draft', art: 'schluss', total: 40000 })
|
||||
await wirft(
|
||||
baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id }),
|
||||
409,
|
||||
'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf der Schlussrechnung. Erst den Entwurf senden oder löschen.'
|
||||
)
|
||||
})
|
||||
|
||||
test('baueRechnung anzahlung/schluss: Voraussetzungen (Schalter, Auftrag, Festpreis, Status, Kunde, Projekt)', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
for (const art of ['anzahlung', 'schluss']) {
|
||||
await wirft(baueRechnung({ customerId: 'k1', art }), 400, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.')
|
||||
await wirft(baueRechnung({ customerId: 'k1', art, auftragId: 'gibtEsNicht' }), 404, 'Auftrag nicht gefunden.')
|
||||
await wirft(baueRechnung({ customerId: 'k2', art, auftragId: ab.id }), 404, 'Auftrag nicht gefunden.')
|
||||
await wirft(baueRechnung({ customerId: 'k1', art, auftragId: ab.id, projektId: 'p3' }), 400, 'Der Auftrag gehört nicht zu diesem Projekt.')
|
||||
config.auftraege.mode = 'off'
|
||||
try {
|
||||
await wirft(baueRechnung({ customerId: 'k1', art, auftragId: ab.id }), 400, SCHALTER_AUS)
|
||||
await wirft(baueRechnung({ customerId: 'k1', art }), 400, SCHALTER_AUS)
|
||||
} finally {
|
||||
config.auftraege.mode = 'on'
|
||||
}
|
||||
}
|
||||
const aufwand = await legeAuftragAn({ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000 }, { nummer: 'AB-2026-0006' })
|
||||
await wirft(baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: aufwand.id }), 400, 'Anzahlung und Schlussrechnung gehen nur mit einer Festpreis-Auftragsbestätigung.')
|
||||
for (const [i, status] of ['entwurf', 'storniert', 'ersetzt', 'widerrufen', 'abgerechnet'].entries()) {
|
||||
const x = await legeAuftragAn({}, { nummer: `AB-2026-010${i}`, status })
|
||||
for (const art of ['anzahlung', 'schluss']) await wirft(baueRechnung({ customerId: 'k1', art, auftragId: x.id }), 400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.')
|
||||
}
|
||||
const bestaetigt = await legeAuftragAn({}, { nummer: 'AB-2026-0007', status: 'bestaetigt', bestaetigtAm: '2026-10-07T09:00:00.000Z' })
|
||||
assert.equal((await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: bestaetigt.id })).summeCents, 40000)
|
||||
})
|
||||
|
||||
test('baueRechnung schluss: AB-Positionen, Zusatz und eine Minuszeile je finalisierter, nicht stornierter früherer Rechnung', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
await fruehereRechnung(ab, { id: 'in_f1', status: 'open', total: 40000, nummer: 'WEBKLAR-0007', finalisiert: '2026-10-08T10:00:00.000Z' })
|
||||
await fruehereRechnung(ab, { id: 'in_f2', status: 'void', total: 20000, nummer: 'WEBKLAR-0008' })
|
||||
const e = await baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id, items: [{ description: 'Domain-Registrierung', quantity: 1, unitAmountCents: 1500 }] })
|
||||
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['ab-1', 60000], ['ab-2', 20000], ['zusatz-1', 1500], ['abzug-in_f1', -40000]])
|
||||
assert.equal(e.positionen.at(-1).beschreibung, 'abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026')
|
||||
assert.equal(e.summeCents, 41500)
|
||||
assert.deepEqual(e.customFields.map((f) => f.name), ['Projekt', 'Auftrag'])
|
||||
assert.equal(e.projektRechnung.art, 'schluss')
|
||||
assert.equal(e.projektRechnung.betragCents, 41500)
|
||||
assert.equal(e.projektRechnung.auftragId, ab.id)
|
||||
})
|
||||
|
||||
test('baueRechnung schluss: offener Entwurf zur AB blockiert (409), Rechnungen fremder ABs und bei Stripe gelöschte zählen nicht, nichts mehr offen -> 409', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
const andere = await legeAuftragAn({}, { nummer: 'AB-2026-0003' })
|
||||
await fruehereRechnung(andere, { id: 'in_g1', status: 'draft', total: 11111 })
|
||||
await fruehereRechnung(andere, { id: 'in_g2', status: 'paid', total: 22222, nummer: 'WEBKLAR-0005' })
|
||||
const lauf = () => baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
|
||||
assert.equal((await lauf()).summeCents, 80000)
|
||||
await fruehereRechnung(ab, { id: 'in_h1', status: 'draft', total: 40000 })
|
||||
await wirft(lauf(), 409, `Erst den Entwurf über 400,00${NBSP}€ senden oder löschen.`)
|
||||
await updateProjektRechnung('in_h1', { aktiv: false })
|
||||
assert.equal((await lauf()).summeCents, 80000)
|
||||
await fruehereRechnung(ab, { id: 'in_h2', status: 'paid', total: 80000, nummer: 'WEBKLAR-0008' })
|
||||
await wirft(lauf(), 409, 'Nichts mehr offen.')
|
||||
await updateProjektRechnung('in_h2', { aktiv: false })
|
||||
// aktiver Projektdatensatz, aber die Rechnung gibt es bei Stripe nicht mehr (im Dashboard gelöscht): zählt nicht
|
||||
await fruehereRechnung(ab, { id: 'in_weg', ohneStripe: true, total: 5000, docNummer: 'X' })
|
||||
const e = await lauf()
|
||||
assert.equal(e.summeCents, 80000)
|
||||
assert.equal(e.positionen.length, 2)
|
||||
assert.equal((await getProjektRechnung('in_weg')).aktiv, false, 'der Datensatz der gelöschten Rechnung wurde freigegeben')
|
||||
})
|
||||
|
||||
test('baueRechnung anzahlung: AB ohne Projekt oder mit gelöschtem Projekt -> Zusatzfeld Projekt zeigt den AB-Titel; ein genanntes fremdes Projekt 404', async () => {
|
||||
const ohne = await legeAuftragAn({ projektId: '', titel: 'Logo-Entwurf' }, { nummer: 'AB-2026-0012' })
|
||||
const e = await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ohne.id })
|
||||
assert.equal(e.projekt, null)
|
||||
assert.deepEqual(e.customFields[0], { name: 'Projekt', value: 'Logo-Entwurf' })
|
||||
assert.equal(e.metadata.projektId, undefined)
|
||||
assert.equal(e.projektRechnung.projektId, '')
|
||||
const weg = await legeAuftragAn({ projektId: 'pWeg' }, { nummer: 'AB-2026-0013' })
|
||||
const w = await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: weg.id })
|
||||
assert.equal(w.projekt, null)
|
||||
assert.deepEqual([w.metadata.projektId, w.projektRechnung.projektId, w.customFields[0].value], ['pWeg', 'pWeg', 'Website anelia-wolf.com'])
|
||||
assert.equal((await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ohne.id, projektId: 'p1' })).projekt.id, 'p1')
|
||||
await wirft(baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ohne.id, projektId: 'p2' }), 404, 'Projekt nicht gefunden.')
|
||||
})
|
||||
|
||||
test('baueRechnung schluss: eine im Dashboard stornierte Schlussrechnung gibt die AB wieder frei (Selbstheilung), eine offene hält sie', async () => {
|
||||
const ab = await legeAuftragAn({}, { status: 'abgerechnet', bestaetigtAm: '2026-10-07T09:00:00.000Z' })
|
||||
await fruehereRechnung(ab, { id: 'in_sl1', art: 'schluss', status: 'open', total: 80000 })
|
||||
await wirft(baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id }), 400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.')
|
||||
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
|
||||
st.invoices.set('in_sl1', { ...st.invoices.get('in_sl1'), status: 'void' }) // im Dashboard storniert
|
||||
const e = await baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
|
||||
assert.equal(e.summeCents, 80000)
|
||||
assert.equal((await getAuftrag(ab.id)).status, 'bestaetigt')
|
||||
assert.equal((await getProjektRechnung('in_sl1')).aktiv, false)
|
||||
})
|
||||
|
||||
test('baueRechnung schluss: bis zu 40 Zeilen (20 AB-Positionen, 10 Nachträge, 10 Abzüge); mehr Abzüge oder Nachträge -> 400', async () => {
|
||||
const positionen = Array.from({ length: 20 }, (_, i) => ({ beschreibung: `Pos ${i + 1}`, menge: 1, einzelpreisCents: 1000 }))
|
||||
const ab = await legeAuftragAn({ positionen, summeCents: 20000 }, { nummer: 'AB-2026-0014' })
|
||||
const nachtraege = (n) => Array.from({ length: n }, (_, i) => ({ description: `Nachtrag ${i + 1}`, quantity: 1, unitAmountCents: 500 }))
|
||||
const lauf = (items) => baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id, items })
|
||||
const abzug = (i) => fruehereRechnung(ab, { id: `in_z${String(i).padStart(2, '0')}`, status: 'paid', total: 100, nummer: `WK-${String(i).padStart(4, '0')}` })
|
||||
assert.equal((await lauf(undefined)).positionen.length, 20)
|
||||
await abzug(1)
|
||||
const eins = await lauf(undefined)
|
||||
assert.deepEqual([eins.positionen.length, eins.summeCents], [21, 19900], '20 AB-Positionen und ein Abzug sind gültig')
|
||||
for (let i = 2; i <= 10; i += 1) await abzug(i)
|
||||
const voll = await lauf(nachtraege(10))
|
||||
assert.deepEqual([voll.positionen.length, voll.summeCents, voll.projektRechnung.betragCents], [40, 24000, 24000])
|
||||
await wirft(lauf(nachtraege(11)), 400, 'Eine Schlussrechnung erlaubt höchstens 10 Zusatzpositionen.')
|
||||
await abzug(11)
|
||||
await wirft(lauf(undefined), 400, 'Die Schlussrechnung hätte 11 Abzüge für frühere Rechnungen; erlaubt sind höchstens 10.')
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
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 { 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 { invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js')
|
||||
const { startTestApp, post, rawGet } = await import('./support/testApp.js')
|
||||
const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js')
|
||||
|
||||
const app = await startTestApp({ '/api/admin/finanzen': financeRoutes })
|
||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||
seedRechnungsDaten(aw)
|
||||
config.auftraege.mode = 'on'
|
||||
beforeEach(async () => {
|
||||
await leereRechnungsDaten(aw, st)
|
||||
invalidateFinanceCache()
|
||||
resetInvoiceCreateGuardForTests()
|
||||
})
|
||||
|
||||
const admin = { headers: { Authorization: 'Bearer jwt-admin' } }
|
||||
const staff = { headers: { Authorization: 'Bearer jwt-staff' } }
|
||||
const base = '/api/admin/finanzen'
|
||||
const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }]
|
||||
const SCHALTER_AUS = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.'
|
||||
const schreibaufrufe = () => st.calls.filter((c) => c.method !== 'GET')
|
||||
const erwarte = async (pfad, body, status, error) => {
|
||||
const res = await post(`${base}${pfad}`, body, admin)
|
||||
assert.equal(res.status, status, JSON.stringify(res.body))
|
||||
assert.deepEqual(res.body, { error })
|
||||
}
|
||||
|
||||
test('POST /rechnungen: der bisherige Körper (ohne art) legt eine freie A4-Rechnung an', async () => {
|
||||
const res = await post(`${base}/rechnungen`, { customerId: 'k1', items: POSTEN, daysUntilDue: 14, memo: '', senden: false, idempotencyKey: 'form-1' }, admin)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual([res.body.rechnung.art, res.body.rechnung.status, res.body.rechnung.totalCents], ['frei', 'entwurf', 1200])
|
||||
assert.equal(st.calls.find((c) => c.method === 'POST' && c.path === '/v1/invoices').body['rendering[pdf][page_size]'], 'a4')
|
||||
})
|
||||
|
||||
test('POST /rechnungen: Festbetrag, Aufwand, Anzahlung und Schluss tragen Projekt, Auftrag und Art in der Antwort', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
const antwort = async (body) => {
|
||||
const res = await post(`${base}/rechnungen`, body, admin)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
return res.body.rechnung
|
||||
}
|
||||
const fest = await antwort({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN })
|
||||
assert.deepEqual([fest.art, fest.projektId, fest.projektName, fest.auftragId], ['festbetrag', 'p1', 'Website anelia-wolf.com', ''])
|
||||
const aufwand = await antwort({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2'] })
|
||||
assert.deepEqual([aufwand.art, aufwand.totalCents], ['aufwand', 22500])
|
||||
const anzahlung = await antwort({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 }, senden: 'true' })
|
||||
assert.deepEqual([anzahlung.art, anzahlung.status, anzahlung.totalCents, anzahlung.auftragNummer], ['anzahlung', 'offen', 20000, 'AB-2026-0002'])
|
||||
const schluss = await antwort({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
|
||||
assert.deepEqual([schluss.art, schluss.totalCents], ['schluss', 60000])
|
||||
})
|
||||
|
||||
test('POST /rechnungen/vorschau: Positionen, Summe, Zeitraum und Zusatzfelder, ohne Stripe-Schreibzugriff und ohne Datensatz', async () => {
|
||||
const res = await post(`${base}/rechnungen/vorschau`, { customerId: 'k1', art: 'aufwand', projektId: 'p1' }, admin)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(Object.keys(res.body).sort(), ['customFields', 'leistungszeitraum', 'positionen', 'summeCents'])
|
||||
assert.equal(res.body.summeCents, 32500)
|
||||
assert.deepEqual(res.body.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07'])
|
||||
assert.equal(res.body.leistungszeitraum, '06.10.–07.10.2026')
|
||||
assert.deepEqual(res.body.customFields.map((f) => f.name), ['Projekt', 'Leistungszeitraum'])
|
||||
assert.equal(schreibaufrufe().length, 0)
|
||||
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
|
||||
})
|
||||
|
||||
test('Fehler kommen mit Status und nur der Meldung an (400, 404, 409), Schalter aus 400; nichts bei Stripe', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) {
|
||||
await erwarte(pfad, { customerId: 'k1', art: 'quatsch', items: POSTEN }, 400, 'Unbekannte Rechnungsart.')
|
||||
await erwarte(pfad, { customerId: 'gibtEsNicht', items: POSTEN }, 400, 'Kunde nicht gefunden.')
|
||||
await erwarte(pfad, { customerId: 'k1', art: 'festbetrag', projektId: 'p2', items: POSTEN }, 404, 'Projekt nicht gefunden.')
|
||||
await erwarte(pfad, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['wsFremd'] }, 400, 'Arbeitsblatt nicht gefunden.')
|
||||
await erwarte(pfad, { customerId: 'k1', art: 'schluss', auftragId: 'gibtEsNicht' }, 404, 'Auftrag nicht gefunden.')
|
||||
config.auftraege.mode = 'off'
|
||||
try {
|
||||
await erwarte(pfad, { customerId: 'k1', art: 'anzahlung', auftragId: ab.id }, 400, SCHALTER_AUS)
|
||||
} finally {
|
||||
config.auftraege.mode = 'on'
|
||||
}
|
||||
}
|
||||
assert.equal(schreibaufrufe().length, 0)
|
||||
})
|
||||
|
||||
test('Abgerechnete Arbeitsblätter: Anlegen und Vorschau antworten 409 mit der Meldung', async () => {
|
||||
const res = await post(`${base}/rechnungen`, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] }, admin)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) {
|
||||
await erwarte(pfad, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws3'], memo: 'zweite' }, 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
|
||||
}
|
||||
})
|
||||
|
||||
test('Falsche Feldtypen im Körper -> 400 mit Meldung, nichts bei Stripe', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
await erwarte('/rechnungen', { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: 'ws1' }, 400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.')
|
||||
await erwarte('/rechnungen', { customerId: 'k1', art: 'aufwand', projektId: 'p1', zeilentexte: ['x'] }, 400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.')
|
||||
await erwarte('/rechnungen', { customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: 'viel' }, 400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.')
|
||||
await erwarte('/rechnungen', { customerId: 'k1', items: 'x' }, 400, 'Eine Rechnung braucht 1 bis 20 Positionen.')
|
||||
await erwarte('/rechnungen', { customerId: 'k1', art: {}, items: POSTEN }, 400, 'Unbekannte Rechnungsart.')
|
||||
assert.equal(schreibaufrufe().length, 0)
|
||||
})
|
||||
|
||||
test('Rechte: ohne Anmeldung 401, Mitarbeiter ohne Admin-Label 403; GET /rechnungen trägt die neuen Felder', async () => {
|
||||
for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) {
|
||||
assert.equal((await post(`${base}${pfad}`, { customerId: 'k1', items: POSTEN })).status, 401)
|
||||
assert.equal((await post(`${base}${pfad}`, { customerId: 'k1', items: POSTEN }, staff)).status, 403)
|
||||
}
|
||||
assert.equal(st.invoices.size, 0)
|
||||
await post(`${base}/rechnungen`, { customerId: 'k1', items: POSTEN, memo: 'frei' }, admin)
|
||||
await post(`${base}/rechnungen`, { customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN }, admin)
|
||||
const liste = (await rawGet(`${base}/rechnungen`, admin)).body.rechnungen
|
||||
const felder = (r) => [r.art, r.projektId, r.projektName, r.auftragId, r.auftragNummer]
|
||||
assert.deepEqual(liste.map(felder).sort(), [['festbetrag', 'p1', 'Website anelia-wolf.com', '', ''], ['frei', '', '', '', '']])
|
||||
})
|
||||
@@ -0,0 +1,288 @@
|
||||
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')
|
||||
})
|
||||
@@ -114,6 +114,11 @@ test('mapInvoice: Felder, Betraege in Cent und Berliner Kalendertage', () => {
|
||||
pdfUrl: 'https://invoice.stripe.test/i/in_1/pdf',
|
||||
outOfBand: false,
|
||||
bankTransactionId: '',
|
||||
projektId: '',
|
||||
projektName: '',
|
||||
art: 'frei',
|
||||
auftragId: '',
|
||||
auftragNummer: '',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -26,7 +26,10 @@ import http from 'node:http'
|
||||
* Wert loescht), .../finalize (nur draft -> open, vergibt die Nummer WK-0001, WK-0002, ... und die Faelligkeit),
|
||||
* .../send (nur open), .../void (nur open -> void), .../pay (nur open -> paid, auch paid_out_of_band; wie die echte API
|
||||
* liefert die Rechnung danach kein Feld paid_out_of_band, nur der Aufruf in calls zeigt es) und
|
||||
* DELETE /v1/invoices/:id (nur draft); jeder andere Zustand ist wie bei Stripe ein 400. Der Kunde einer neuen
|
||||
* DELETE /v1/invoices/:id (nur draft); jeder andere Zustand ist wie bei Stripe ein 400.
|
||||
* custom_fields (hoechstens 4, Name 1 bis 40, Wert 1 bis 140 Zeichen) und rendering[pdf][page_size] (a4, auto, letter) werden wie
|
||||
* bei Stripe geprueft, gespeichert (sonst null) und mit der Rechnung geliefert; Rechnungsposten mit amount (auch negativ)
|
||||
* tragen weder quantity noch unit_amount_decimal. Der Kunde einer neuen
|
||||
* 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:
|
||||
@@ -461,6 +464,22 @@ export async function startFakeStripe() {
|
||||
}
|
||||
const metadata = mergeMetadata({}, data.metadata)
|
||||
if (Array.isArray(metadata)) return metadata
|
||||
const customFields = data.custom_fields
|
||||
if (customFields !== undefined) {
|
||||
if (!Array.isArray(customFields) || customFields.length > 4) return invalid('custom_fields', 'You can specify up to 4 custom fields.')
|
||||
for (const [i, field] of customFields.entries()) {
|
||||
if (typeof field?.name !== 'string' || field.name.length < 1 || field.name.length > 40) {
|
||||
return invalid(`custom_fields[${i}][name]`, 'Custom field names must be between 1 and 40 characters.')
|
||||
}
|
||||
if (typeof field.value !== 'string' || field.value.length < 1 || field.value.length > 140) {
|
||||
return invalid(`custom_fields[${i}][value]`, 'Custom field values must be between 1 and 140 characters.')
|
||||
}
|
||||
}
|
||||
}
|
||||
const pageSize = data.rendering?.pdf?.page_size
|
||||
if (pageSize !== undefined && !['a4', 'auto', 'letter'].includes(pageSize)) {
|
||||
return invalid('rendering[pdf][page_size]', 'Invalid page_size: must be one of a4, auto, letter.')
|
||||
}
|
||||
const customer = customers.get(data.customer)
|
||||
const invoice = {
|
||||
id: nextId('in'),
|
||||
@@ -479,6 +498,8 @@ export async function startFakeStripe() {
|
||||
description: data.description ?? null,
|
||||
footer: data.footer ?? null,
|
||||
metadata,
|
||||
custom_fields: customFields ?? null,
|
||||
rendering: data.rendering ?? null,
|
||||
created: nowUnix(),
|
||||
total: 0,
|
||||
subtotal: 0,
|
||||
@@ -496,6 +517,9 @@ export async function startFakeStripe() {
|
||||
if (method === 'POST' && path === '/v1/invoiceitems') {
|
||||
const data = unflatten(body)
|
||||
if (!data.customer) return invalid('customer', 'Missing required param: customer.')
|
||||
if (data.amount !== undefined && (data.quantity !== undefined || data.unit_amount_decimal !== undefined)) {
|
||||
return invalid('amount', 'You may only specify one of these parameters: amount, or quantity with unit_amount_decimal.')
|
||||
}
|
||||
const quantity = data.quantity !== undefined ? Number(data.quantity) : 1
|
||||
if (!Number.isInteger(quantity) || quantity < 0) return invalid('quantity', 'Invalid non-negative integer: quantity.')
|
||||
let unit = null
|
||||
|
||||
Reference in new issue
Block a user