334 lines
15 KiB
JavaScript
334 lines
15 KiB
JavaScript
import { test, after, beforeEach } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import http from 'node:http'
|
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
|
import { startFakeStripe } from './support/fakeStripe.js'
|
|
import { useTestEnv } from './support/testEnv.js'
|
|
import { schemaKeys } from './support/schema.js'
|
|
|
|
/**
|
|
* Kunden-Tab "Rechnungen" (2026-10-02): Stripe-Rechnungen (Abo, Funktionen, Projektrechnungen aus dem WOMS) stehen
|
|
* neben den InvoiceNinja-Rechnungen. Vorher zeigte der Tab nur InvoiceNinja, eine bezahlte Abo-Rechnung fehlte.
|
|
*/
|
|
|
|
// Kleiner InvoiceNinja-Nachbau: Rechnungsliste je Client, eine Rechnung mit Einladung, PDF ueber den Einladungsschluessel
|
|
let ninjaDown = false
|
|
const ninjaInvoices = [
|
|
{ id: 'inv_in_1', client_id: 'inc1', number: '2026-001', date: '', due_date: '', amount: 168.79, balance: 0, status_id: 4, is_deleted: false, invitations: [{ key: 'key_in_1' }] },
|
|
{ id: 'inv_in_entwurf', client_id: 'inc1', number: '', date: '', due_date: '', amount: 50, balance: 50, status_id: 1, is_deleted: false, invitations: [] },
|
|
]
|
|
const ninja = http.createServer((req, res) => {
|
|
const url = new URL(req.url, 'http://127.0.0.1')
|
|
const json = (status, body) => {
|
|
res.writeHead(status, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify(body))
|
|
}
|
|
if (ninjaDown) return json(500, { message: 'Nachbau: InvoiceNinja nicht erreichbar' })
|
|
if (url.pathname === '/api/v1/invoices') {
|
|
return json(200, { data: ninjaInvoices.filter((inv) => inv.client_id === url.searchParams.get('client_id')) })
|
|
}
|
|
const one = url.pathname.match(/^\/api\/v1\/invoices\/([^/]+)$/)
|
|
if (one) {
|
|
const inv = ninjaInvoices.find((i) => i.id === one[1])
|
|
return inv ? json(200, { data: inv }) : json(404, { message: 'Not found' })
|
|
}
|
|
if (/^\/client\/invoice\/key_in_1\/download_pdf$/.test(url.pathname)) {
|
|
res.writeHead(200, { 'Content-Type': 'application/pdf' })
|
|
return res.end('%PDF-1.4\n% InvoiceNinja-Nachbau\n%%EOF\n')
|
|
}
|
|
return json(404, { message: 'Not found' })
|
|
})
|
|
await new Promise((resolve) => ninja.listen(0, '127.0.0.1', resolve))
|
|
|
|
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
|
|
const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions') } })
|
|
const st = await startFakeStripe()
|
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
|
process.env.INVOICE_NINJA_URL = `http://127.0.0.1:${ninja.address().port}`
|
|
process.env.INVOICE_NINJA_API_TOKEN = 'in-test-token'
|
|
const { startTestApp, rawGet } = await import('./support/testApp.js')
|
|
const { customers, customerCookie, seedFixtures } = await import('./support/fixtures.js')
|
|
const { default: customerDataRoutes } = await import('../server/routes/customerData.js')
|
|
const { customerStripeInvoice, isStripeInvoiceId, stripeCustomerIdsFor } = await import('../server/services/customerInvoices.js')
|
|
const { berlinDate } = await import('../server/services/finance/dates.js')
|
|
|
|
const app = await startTestApp({ '/api': customerDataRoutes })
|
|
after(() => Promise.all([app.close(), aw.close(), st.close(), new Promise((resolve) => ninja.close(resolve))]))
|
|
seedFixtures(aw)
|
|
// c1: Abo sub_h (Stripe-Kunde cus_c1) und dazu ein InvoiceNinja-Client (Altbestand)
|
|
aw.seed('customers', { ...customers.c1, invoiceNinjaClientId: 'inc1' })
|
|
|
|
const DAY = 86_400
|
|
const nowUnix = () => Math.floor(Date.now() / 1000)
|
|
// Berliner Mittag vor n Tagen (Unix-Sekunden), damit das Rechnungsdatum eindeutig ist
|
|
const daysAgo = (n) => Math.floor(new Date(`${berlinDate(new Date(Date.now() - n * DAY * 1000))}T12:00:00+02:00`).getTime() / 1000)
|
|
const ymdDaysAgo = (n) => berlinDate(new Date(daysAgo(n) * 1000))
|
|
|
|
const stripeInvoice = (id, customer, over = {}) => ({
|
|
id,
|
|
object: 'invoice',
|
|
customer,
|
|
number: id.toUpperCase(),
|
|
status: 'paid',
|
|
collection_method: 'send_invoice',
|
|
created: daysAgo(5),
|
|
effective_at: daysAgo(5),
|
|
status_transitions: { finalized_at: daysAgo(5), paid_at: daysAgo(5) },
|
|
due_date: null,
|
|
total: 1000,
|
|
amount_due: 1000,
|
|
amount_paid: 1000,
|
|
amount_remaining: 0,
|
|
hosted_invoice_url: `https://invoice.stripe.com/i/${id}`,
|
|
invoice_pdf: `${st.url}/pdf/invoices/${id}`,
|
|
metadata: {},
|
|
lines: { object: 'list', data: [], has_more: false },
|
|
...over,
|
|
})
|
|
|
|
function seedStripe() {
|
|
st.invoices.clear()
|
|
st.customers.clear()
|
|
// cus_c1 haengt am Abo und traegt die Metadaten; cus_c1b nur per Suche (z. B. Projektrechnung ohne Abo-Kunden)
|
|
st.customers.set('cus_c1', { id: 'cus_c1', object: 'customer', metadata: { webklarCustomerId: 'c1' } })
|
|
st.customers.set('cus_c1b', { id: 'cus_c1b', object: 'customer', metadata: { webklarCustomerId: 'c1' } })
|
|
st.customers.set('cus_c2', { id: 'cus_c2', object: 'customer', metadata: { webklarCustomerId: 'c2' } })
|
|
st.customers.set('cus_c3', { id: 'cus_c3', object: 'customer', metadata: { webklarCustomerId: 'c3' } })
|
|
const all = [
|
|
// Wartungs-Abo, bezahlt (wie Martins erste Abo-Rechnung)
|
|
stripeInvoice('in_c1_abo', 'cus_c1', {
|
|
number: 'H96T-0004',
|
|
collection_method: 'charge_automatically',
|
|
effective_at: daysAgo(3),
|
|
total: 2500,
|
|
amount_due: 2500,
|
|
amount_paid: 2500,
|
|
parent: { subscription_details: { subscription: 'sub_h' } },
|
|
}),
|
|
stripeInvoice('in_c1_offen', 'cus_c1', {
|
|
status: 'open',
|
|
effective_at: daysAgo(1),
|
|
due_date: nowUnix() + 18 * DAY,
|
|
total: 12000,
|
|
amount_due: 12000,
|
|
amount_paid: 0,
|
|
amount_remaining: 12000,
|
|
}),
|
|
stripeInvoice('in_c1_alt', 'cus_c1', {
|
|
status: 'open',
|
|
effective_at: daysAgo(40),
|
|
due_date: daysAgo(26),
|
|
total: 9900,
|
|
amount_due: 9900,
|
|
amount_paid: 0,
|
|
amount_remaining: 9900,
|
|
}),
|
|
stripeInvoice('in_c1_storno', 'cus_c1', { status: 'void', effective_at: daysAgo(10), total: 5000, amount_paid: 0, amount_remaining: 5000 }),
|
|
stripeInvoice('in_c1_uneinbringlich', 'cus_c1', { status: 'uncollectible', effective_at: daysAgo(20), total: 3000, amount_paid: 0, amount_remaining: 3000 }),
|
|
// nie sichtbar: Entwurf (ohne Nummer und Links) und 0-€-Rechnung (Probezeit)
|
|
stripeInvoice('in_c1_entwurf', 'cus_c1', { status: 'draft', number: null, effective_at: null, hosted_invoice_url: null, invoice_pdf: null }),
|
|
stripeInvoice('in_c1_null', 'cus_c1', { effective_at: daysAgo(4), total: 0, amount_due: 0, amount_paid: 0 }),
|
|
stripeInvoice('in_c1b_projekt', 'cus_c1b', { number: 'WK-0001', effective_at: daysAgo(2), total: 45000, amount_due: 45000, amount_paid: 45000 }),
|
|
stripeInvoice('in_c2_abo', 'cus_c2', { effective_at: daysAgo(1) }),
|
|
stripeInvoice('in_c3_projekt', 'cus_c3', { status: 'open', due_date: nowUnix() + 10 * DAY, amount_paid: 0, amount_remaining: 1000 }),
|
|
]
|
|
for (const invoice of all) st.invoices.set(invoice.id, invoice)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
ninjaDown = false
|
|
st.calls.length = 0
|
|
seedStripe()
|
|
})
|
|
|
|
const listFor = (customerId) => rawGet('/api/invoices', { cookie: customerCookie(customerId) })
|
|
const ids = (body) => body.invoices.map((i) => i.id)
|
|
|
|
test('ohne Anmeldung: 401 fuer Liste und PDF', async () => {
|
|
assert.equal((await rawGet('/api/invoices')).status, 401)
|
|
assert.equal((await rawGet('/api/invoices/in_c1_abo/pdf')).status, 401)
|
|
})
|
|
|
|
test('Liste: Stripe-Rechnungen aller Stripe-Kunden des Kunden und InvoiceNinja, neueste zuerst', async () => {
|
|
const { status, body } = await listFor('c1')
|
|
assert.equal(status, 200)
|
|
assert.equal(body.incomplete, false)
|
|
assert.deepEqual(ids(body), [
|
|
'in_c1_offen',
|
|
'in_c1b_projekt',
|
|
'in_c1_abo',
|
|
'in_c1_storno',
|
|
'in_c1_uneinbringlich',
|
|
'in_c1_alt',
|
|
'inv_in_1',
|
|
])
|
|
// Jeder Stripe-Kunde genau einmal gefragt (cus_c1 steht im Abo und in der Suche), nie ein fremder
|
|
const asked = st.calls.filter((c) => c.method === 'GET' && c.path === '/v1/invoices').map((c) => c.query.customer)
|
|
assert.deepEqual(asked.sort(), ['cus_c1', 'cus_c1b'])
|
|
})
|
|
|
|
test('Liste: Felder einer bezahlten Abo-Rechnung (Betraege in Euro, Link zur Rechnungsseite, PDF)', async () => {
|
|
const { body } = await listFor('c1')
|
|
const abo = body.invoices.find((i) => i.id === 'in_c1_abo')
|
|
assert.deepEqual(abo, {
|
|
id: 'in_c1_abo',
|
|
source: 'stripe',
|
|
number: 'H96T-0004',
|
|
date: ymdDaysAgo(3),
|
|
dueDate: '',
|
|
amount: 25,
|
|
balance: 0,
|
|
status: 'Bezahlt',
|
|
paid: true,
|
|
cancelled: false,
|
|
overdue: false,
|
|
portalUrl: 'https://invoice.stripe.com/i/in_c1_abo',
|
|
hasPdf: true,
|
|
projekt: '',
|
|
})
|
|
})
|
|
|
|
test('Liste: offen, ueberfaellig, storniert, uneinbringlich und InvoiceNinja-Altbestand', async () => {
|
|
const { body } = await listFor('c1')
|
|
const by = Object.fromEntries(body.invoices.map((i) => [i.id, i]))
|
|
assert.equal(by.in_c1_offen.status, 'Offen')
|
|
assert.equal(by.in_c1_offen.balance, 120)
|
|
assert.equal(by.in_c1_offen.overdue, false)
|
|
assert.equal(by.in_c1_alt.status, 'Überfällig')
|
|
assert.equal(by.in_c1_alt.overdue, true)
|
|
assert.equal(by.in_c1_alt.balance, 99)
|
|
assert.equal(by.in_c1_storno.status, 'Storniert')
|
|
assert.equal(by.in_c1_storno.cancelled, true)
|
|
assert.equal(by.in_c1_storno.balance, 0)
|
|
// Stripe nimmt die Zahlung einer uneinbringlichen Rechnung weiter an: fuer den Kunden offen
|
|
assert.equal(by.in_c1_uneinbringlich.status, 'Offen')
|
|
assert.equal(by.in_c1_uneinbringlich.balance, 30)
|
|
assert.equal(by.inv_in_1.source, 'invoiceninja')
|
|
assert.equal(by.inv_in_1.paid, true)
|
|
assert.equal(by.inv_in_1.status, 'Bezahlt')
|
|
// Entwuerfe (Stripe und InvoiceNinja) und 0-€-Rechnungen sieht der Kunde nicht
|
|
assert.equal(by.in_c1_entwurf, undefined)
|
|
assert.equal(by.in_c1_null, undefined)
|
|
assert.equal(by.inv_in_entwurf, undefined)
|
|
})
|
|
|
|
test('Liste: Kunde ohne Abo findet seine Projektrechnung ueber die Stripe-Suche; fremde Rechnungen nie', async () => {
|
|
const c3 = await listFor('c3')
|
|
assert.equal(c3.status, 200)
|
|
assert.deepEqual(ids(c3.body), ['in_c3_projekt'])
|
|
const c2 = await listFor('c2')
|
|
assert.deepEqual(ids(c2.body), ['in_c2_abo'])
|
|
})
|
|
|
|
test('Liste: faellt Stripe aus, kommen die InvoiceNinja-Rechnungen mit incomplete', async () => {
|
|
st.failNext({ method: 'GET', path: '/v1/customers/search', status: 400 })
|
|
const { status, body } = await listFor('c1')
|
|
assert.equal(status, 200)
|
|
assert.equal(body.incomplete, true)
|
|
assert.deepEqual(ids(body), ['inv_in_1'])
|
|
})
|
|
|
|
test('Liste: faellt InvoiceNinja aus, kommen die Stripe-Rechnungen mit incomplete', async () => {
|
|
ninjaDown = true
|
|
const { status, body } = await listFor('c1')
|
|
assert.equal(status, 200)
|
|
assert.equal(body.incomplete, true)
|
|
assert.equal(ids(body).length, 6)
|
|
assert.ok(ids(body).every((id) => id.startsWith('in_')))
|
|
})
|
|
|
|
test('Liste: fallen alle befragten Quellen aus, gibt es 502 mit fester Meldung (ohne Doppelung auf der Seite)', async () => {
|
|
ninjaDown = true
|
|
st.failNext({ method: 'GET', path: '/v1/customers/search', status: 400 })
|
|
const both = await listFor('c1')
|
|
assert.equal(both.status, 502)
|
|
// Die Seite stellt "Rechnungen konnten nicht geladen werden: " voran
|
|
assert.equal(both.body.error, 'Bitte versuche es später noch einmal.')
|
|
// c3 hat kein InvoiceNinja: Stripe ist die einzige Quelle
|
|
st.failNext({ method: 'GET', path: '/v1/invoices', status: 400 })
|
|
const only = await listFor('c3')
|
|
assert.equal(only.status, 502)
|
|
})
|
|
|
|
test('PDF: eigene Stripe-Rechnung kommt ueber den Server als Download', async () => {
|
|
const res = await rawGet('/api/invoices/in_c1_abo/pdf', { cookie: customerCookie('c1') })
|
|
assert.equal(res.status, 200)
|
|
assert.equal(res.headers.get('content-type'), 'application/pdf')
|
|
assert.equal(res.headers.get('content-disposition'), 'attachment; filename="Rechnung-H96T-0004.pdf"')
|
|
assert.equal(res.headers.get('cache-control'), 'no-store')
|
|
assert.match(res.body, /^%PDF-1\.4/)
|
|
assert.ok(st.calls.some((c) => c.method === 'GET' && c.path === '/pdf/invoices/in_c1_abo'))
|
|
})
|
|
|
|
test('PDF: fremde, unbekannte und Entwurfs-Rechnungen geben 404, ohne das PDF zu laden', async () => {
|
|
for (const id of ['in_c2_abo', 'in_gibtsnicht', 'in_c1_entwurf']) {
|
|
const res = await rawGet(`/api/invoices/${id}/pdf`, { cookie: customerCookie('c1') })
|
|
assert.equal(res.status, 404, id)
|
|
assert.equal(res.body.error, 'Rechnung nicht gefunden', id)
|
|
}
|
|
assert.equal(st.calls.filter((c) => c.path.startsWith('/pdf/')).length, 0)
|
|
})
|
|
|
|
test('PDF: ein Link, der nicht zu Stripe fuehrt, wird nicht geladen (502)', async () => {
|
|
st.invoices.set('in_c1_fremd', stripeInvoice('in_c1_fremd', 'cus_c1', { invoice_pdf: 'https://example.invalid/rechnung.pdf' }))
|
|
const res = await rawGet('/api/invoices/in_c1_fremd/pdf', { cookie: customerCookie('c1') })
|
|
assert.equal(res.status, 502)
|
|
assert.equal(res.body.error, 'PDF konnte nicht geladen werden')
|
|
})
|
|
|
|
test('PDF: liefert Stripe das PDF nicht, gibt es 502 mit fester Meldung', async () => {
|
|
st.failNext({ method: 'GET', path: /^\/pdf\/invoices\//, status: 500 })
|
|
const res = await rawGet('/api/invoices/in_c1_abo/pdf', { cookie: customerCookie('c1') })
|
|
assert.equal(res.status, 502)
|
|
assert.equal(res.body.error, 'PDF konnte nicht geladen werden')
|
|
})
|
|
|
|
test('PDF: InvoiceNinja-Rechnungen laufen weiter ueber InvoiceNinja', async () => {
|
|
const res = await rawGet('/api/invoices/inv_in_1/pdf', { cookie: customerCookie('c1') })
|
|
assert.equal(res.status, 200)
|
|
assert.equal(res.headers.get('content-disposition'), 'attachment; filename="Rechnung-2026-001.pdf"')
|
|
assert.match(res.body, /InvoiceNinja-Nachbau/)
|
|
// fremder Kunde (c3 hat keinen InvoiceNinja-Client): 404
|
|
assert.equal((await rawGet('/api/invoices/inv_in_1/pdf', { cookie: customerCookie('c3') })).status, 404)
|
|
})
|
|
|
|
test('customerStripeInvoice: Berliner Rechnungsdatum und Faelligkeit gegen den Berliner Tag', () => {
|
|
const at = (iso) => Math.floor(new Date(iso).getTime() / 1000)
|
|
const raw = {
|
|
id: 'in_x',
|
|
number: 'WK-0002',
|
|
status: 'open',
|
|
// 22:30 UTC = 00:30 MESZ am Folgetag
|
|
effective_at: at('2026-09-29T22:30:00Z'),
|
|
due_date: at('2026-10-01T10:00:00Z'),
|
|
total: 1999,
|
|
amount_remaining: 1999,
|
|
hosted_invoice_url: null,
|
|
invoice_pdf: null,
|
|
}
|
|
const sameDay = customerStripeInvoice(raw, { today: '2026-10-01' })
|
|
assert.equal(sameDay.date, '2026-09-30')
|
|
assert.equal(sameDay.dueDate, '2026-10-01')
|
|
assert.equal(sameDay.overdue, false)
|
|
assert.equal(sameDay.status, 'Offen')
|
|
assert.equal(sameDay.amount, 19.99)
|
|
assert.equal(sameDay.portalUrl, '')
|
|
assert.equal(sameDay.hasPdf, false)
|
|
const nextDay = customerStripeInvoice(raw, { today: '2026-10-02' })
|
|
assert.equal(nextDay.overdue, true)
|
|
assert.equal(nextDay.status, 'Überfällig')
|
|
// ohne effective_at: Festschreibung, sonst Anlage
|
|
assert.equal(customerStripeInvoice({ ...raw, effective_at: null, status_transitions: { finalized_at: at('2026-09-01T10:00:00Z') } }).date, '2026-09-01')
|
|
assert.equal(customerStripeInvoice({ ...raw, effective_at: null, created: at('2026-08-15T10:00:00Z') }).date, '2026-08-15')
|
|
})
|
|
|
|
test('isStripeInvoiceId und Stripe-Suche nur mit sicheren Kunden-IDs', async () => {
|
|
assert.equal(isStripeInvoiceId('in_1UL6LPBsRA1AOdnegGQ6D6IS'), true)
|
|
assert.equal(isStripeInvoiceId('Opnel5aKBz'), false)
|
|
assert.equal(isStripeInvoiceId('in_../x'), false)
|
|
assert.equal(isStripeInvoiceId('in_'), false)
|
|
const before = st.calls.length
|
|
assert.deepEqual([...(await stripeCustomerIdsFor("c1'] OR metadata['x"))], [])
|
|
assert.equal(st.calls.length, before)
|
|
})
|