diff --git a/public/neu/rechnung-offen.js b/public/neu/rechnung-offen.js new file mode 100644 index 0000000..188e541 --- /dev/null +++ b/public/neu/rechnung-offen.js @@ -0,0 +1,36 @@ +/* Kundenansicht neu (P2): Zeile "Rechnung offen" für Website und Funktionen. Offen heißt wie überall Number(balance) > 0 (V3). + Fällt der Abruf aus, fehlt nur diese Zeile. */ + +import { api } from './api.js' +import { h } from './dom.js' +import { fmtEuro, safeUrl, zuAnsicht } from './ansicht-hilfen.js' + +/** Rechnungen mit offenem Betrag, in der Reihenfolge der Antwort von GET /api/invoices. */ +export function offeneRechnungen(antwort) { + const liste = Array.isArray(antwort?.invoices) ? antwort.invoices : [] + return liste.filter((r) => Number(r?.balance) > 0) +} + +/** Lädt GET /api/invoices und liefert die offenen Rechnungen; jeder Fehler ergibt eine leere Liste. */ +export async function ladeOffeneRechnungen(apiFn = api) { + try { + return offeneRechnungen(await apiFn('/api/invoices')) + } catch { + return [] + } +} + +/** Die Zeile für die offenen Rechnungen; ohne offene Rechnung null. Eine Rechnung mit sicherem Link führt dorthin, sonst zu den Rechnungen. */ +export function rechnungOffenZeile(offen) { + if (!Array.isArray(offen) || !offen.length) return null + const summe = Math.round(offen.reduce((s, r) => s + (Number(r.balance) || 0), 0) * 100) / 100 + const einzel = offen.length === 1 ? offen[0] : null + const url = einzel ? safeUrl(einzel.portalUrl) : '' + const link = url + ? h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, 'Zahlen') + : h('a', zuAnsicht('invoices'), 'Zahlen') + const teile = einzel + ? ['Rechnung offen', einzel.number, fmtEuro(summe)] + : [`${offen.length} Rechnungen offen`, fmtEuro(summe)] + return h('p', { class: 'kn-rechnung-offen' }, `${teile.filter(Boolean).join(' · ')} · `, link) +} diff --git a/test/knAnsichtRechnungOffen.test.js b/test/knAnsichtRechnungOffen.test.js new file mode 100644 index 0000000..c394642 --- /dev/null +++ b/test/knAnsichtRechnungOffen.test.js @@ -0,0 +1,22 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { offeneRechnungen, ladeOffeneRechnungen, rechnungOffenZeile } from '../public/neu/rechnung-offen.js' +import { mit, mitDom, rechnung } from './support/knDaten.js' + +const NB = ' ' +test('eine offene Rechnung: Text und Link auf die Rechnung', () => mitDom((fx) => { + const p = rechnungOffenZeile([rechnung({ number: 'RE-2026-0042', balance: 50, portalUrl: 'https://invoice.stripe.com/i/x' })]) + assert.equal(p.textContent, `Rechnung offen · RE-2026-0042 · 50,00${NB}€ · Zahlen`) + const a = fx.finde(p, 'a'); assert.equal(a.getAttribute('href'), 'https://invoice.stripe.com/i/x'); assert.equal(a.getAttribute('rel'), 'noopener noreferrer') +})) +test('mehrere offene: Summe, Link über die Brücke zu #rechnungen; unsicherer Link fällt darauf zurück', () => mitDom((fx) => { + const zwei = [rechnung({ balance: 100 }), rechnung({ balance: 50 })] + assert.equal(rechnungOffenZeile(zwei).textContent, `2 Rechnungen offen · 150,00${NB}€ · Zahlen`) + const a = fx.finde(rechnungOffenZeile(zwei), 'a'); assert.deepEqual([a.getAttribute('href'), a.getAttribute('data-view')], ['#rechnungen', 'invoices']) + assert.equal(fx.finde(rechnungOffenZeile([rechnung({ balance: 5, portalUrl: 'javascript:alert(1)' })]), 'a').getAttribute('href'), '#rechnungen') + assert.equal(rechnungOffenZeile([]), null) +})) +test('offeneRechnungen filtert nach Betrag, ladeOffeneRechnungen schluckt Fehler', mit({ 'GET /api/invoices': () => ({ status: 500, body: { error: 'x' } }) }, async () => { + assert.deepEqual(offeneRechnungen({ invoices: [rechnung({ balance: 0 }), rechnung({ id: 'b', balance: 1 })] }).map((r) => r.id), ['b']) + assert.deepEqual(await ladeOffeneRechnungen(), []) +}))