diff --git a/server/services/auftraege/projektkonto.js b/server/services/auftraege/projektkonto.js index cf41592..a344ce4 100644 --- a/server/services/auftraege/projektkonto.js +++ b/server/services/auftraege/projektkonto.js @@ -1,5 +1,8 @@ import { STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS } from './konstanten.js' import { abrechenbar } from './arbeitsblaetter.js' +import { getStripe } from '../stripeBilling.js' +import { berlinDate } from '../finance/dates.js' +import { listInvoices, mapInvoice } from '../finance/stripeFinance.js' /** * Projektkonto (Spec 2026-10-06, Abschnitt 4; Uebersicht V4 "L1 - Projektkonto"): reine Rechenregeln und die Ladefunktionen @@ -91,3 +94,42 @@ export function rechneOhneProjekt(rechnungen) { const gezaehlt = rechnungen.filter((invoice) => FINALISIERT.has(invoice.status)) return { berechnetCents: summe(gezaehlt, 'totalCents'), bezahltCents: summe(gezaehlt, 'paidCents'), anzahl: gezaehlt.length } } + +// Einzellesen fehlender Rechnungen: wie die Leseanfragen in stripeFinance.js (15 s, eine Wiederholung); stripeFinance.js +// haelt seine Optionen privat, daher hier eine eigene Konstante +const LESE_OPTIONEN = { timeout: 15_000, maxNetworkRetries: 1 } +const EINZELN_PARALLEL = 10 +// Format der Rechnungs-ID wie im Schema der Projektrechnungen (V1); die IDs des Stripe-Nachbaus (in_test_) passen auch +const STRIPE_RECHNUNG = /^in_\w{1,33}$/ + +/** Eine Rechnung frisch von Stripe in mapInvoice-Form; null, wenn es sie nicht (mehr) gibt (404); andere Fehler fliegen. */ +async function leseEinzeln(invoiceId) { + try { + const raw = await getStripe().invoices.retrieve(invoiceId, { expand: ['customer'] }, LESE_OPTIONEN) + return mapInvoice(raw, { today: berlinDate() }) + } catch (err) { + if (err?.statusCode === 404 || err?.code === 'resource_missing') return null + throw err + } +} + +/** + * Rechnungsdaten fuer eine Liste von Projektrechnungen: die Rechnungen der letzten 400 Tage aus listInvoices() (60-Sekunden- + * Zwischenspeicher); was dort fehlt (aelter, ueber 1000, frisch angelegt), wird einzeln gelesen, hoechstens 10 gleichzeitig. + * Scheitert Stripe (Liste oder Einzellesen ausser 404), ist ok false und alles leer; ins Protokoll kommen nur Art und Status. + */ +export async function ladeStripeRechnungen(projektRechnungen) { + try { + const alle = await listInvoices() + const nachId = new Map(alle.map((invoice) => [invoice.id, invoice])) + const fehlend = [...new Set(projektRechnungen.map((eintrag) => eintrag.invoiceId))].filter((id) => STRIPE_RECHNUNG.test(id) && !nachId.has(id)) + for (let i = 0; i < fehlend.length; i += EINZELN_PARALLEL) { + const gelesen = await Promise.all(fehlend.slice(i, i + EINZELN_PARALLEL).map(leseEinzeln)) + for (const invoice of gelesen) if (invoice) nachId.set(invoice.id, invoice) + } + return { ok: true, alle, nachId } + } catch (err) { + console.warn('[projektkonto] Stripe nicht lesbar:', err?.type || err?.name || 'Error', err?.statusCode ?? err?.status ?? '') + return { ok: false, alle: [], nachId: new Map() } + } +} diff --git a/test/auftraegeProjektkonto.test.js b/test/auftraegeProjektkonto.test.js index 4c805f2..e55f547 100644 --- a/test/auftraegeProjektkonto.test.js +++ b/test/auftraegeProjektkonto.test.js @@ -211,3 +211,86 @@ test('rechneOhneProjekt: nur finalisierte Rechnungen zählen', () => { assert.deepEqual(rechneOhneProjekt(rechnungen), { berechnetCents: 23000, bezahltCents: 8500, anzahl: 4 }) assert.deepEqual(rechneOhneProjekt([]), { berechnetCents: 0, bezahltCents: 0, anzahl: 0 }) }) + +// ---------- Stripe lesen (Task 3) ---------- + +const noonBerlin = (ymd) => Math.floor(new Date(`${ymd}T12:00:00+02:00`).getTime() / 1000) +// Rohe Stripe-Rechnung wie in financeStripeRead.test.js; status in Stripes Schreibweise (draft, open, paid, void, uncollectible) +const stripeInvoice = ( + id, + { status = 'open', total = 0, paid = 0, number = '', kunde = 'c1', created = '2026-10-01', due = '2099-12-31', abo = false } = {} +) => ({ + id, + object: 'invoice', + number: number || null, + status, + customer: `cus_${kunde}`, + customer_name: 'Kunde', + customer_email: 'k@example.invalid', + created: noonBerlin(created), + due_date: noonBerlin(due), + total, + amount_due: total, + amount_paid: paid, + amount_remaining: total - paid, + status_transitions: { paid_at: null }, + metadata: { webklarCustomerId: kunde, quelle: 'woms' }, + ...(abo ? { parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_1' } } } : {}), + lines: { object: 'list', data: [], has_more: false }, +}) +const pr = (invoiceId) => ({ invoiceId }) + +test('ladeStripeRechnungen: Liste zuerst, Fehlendes einzeln gelesen, Unbekanntes bleibt aussen vor', async () => { + st.invoices.set('in_1', stripeInvoice('in_1', { total: 4000, number: 'WEBKLAR-0001' })) + await listInvoices() // füllt den 60-Sekunden-Zwischenspeicher; in_9 fehlt dort + st.invoices.set('in_9', stripeInvoice('in_9', { status: 'paid', total: 7000, paid: 7000, number: 'WEBKLAR-0009' })) + st.calls.length = 0 + const result = await ladeStripeRechnungen([pr('in_1'), pr('in_9'), pr('in_404')]) + assert.equal(result.ok, true) + assert.deepEqual([...result.nachId.keys()].sort(), ['in_1', 'in_9']) + assert.deepEqual(result.alle.map((invoice) => invoice.id), ['in_1']) + const gelesen = result.nachId.get('in_9') + assert.deepEqual([gelesen.status, gelesen.totalCents, gelesen.paidCents], ['bezahlt', 7000, 7000]) + assert.deepEqual(st.calls.map((call) => `${call.method} ${call.path}`).sort(), ['GET /v1/invoices/in_404', 'GET /v1/invoices/in_9']) +}) + +test('ladeStripeRechnungen: Stripe-Ausfall ergibt ok false und ein Protokoll ohne Fehlertext', async () => { + const warn = mock.method(console, 'warn', () => {}) + st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'geheim-kaputt' }) + const result = await ladeStripeRechnungen([pr('in_1')]) + assert.deepEqual([result.ok, result.alle, [...result.nachId.keys()]], [false, [], []]) + assert.equal(warn.mock.callCount(), 1) + assert.ok(!JSON.stringify(warn.mock.calls[0].arguments).includes('geheim-kaputt')) +}) + +test('ladeStripeRechnungen: ein anderer Fehler als 404 beim Einzellesen ist ein Ausfall', async () => { + mock.method(console, 'warn', () => {}) + st.invoices.set('in_1', stripeInvoice('in_1', { total: 4000 })) + await listInvoices() + st.invoices.set('in_9', stripeInvoice('in_9', { total: 7000 })) + st.failNext({ method: 'GET', path: '/v1/invoices/in_9', status: 400, message: 'kaputt' }) + const result = await ladeStripeRechnungen([pr('in_1'), pr('in_9')]) + assert.equal(result.ok, false) + assert.equal(result.nachId.size, 0) +}) + +test('ladeStripeRechnungen: viele fehlende Rechnungen werden vollständig gelesen (auch IDs wie die des Stripe-Nachbaus)', async () => { + await listInvoices() + const ids = Array.from({ length: 25 }, (_, i) => `in_test_${i + 1}`) + for (const id of ids) st.invoices.set(id, stripeInvoice(id, { status: 'paid', total: 100, paid: 100 })) + st.calls.length = 0 + const result = await ladeStripeRechnungen(ids.map(pr)) + assert.equal(result.ok, true) + assert.equal(result.nachId.size, 25) + assert.equal(st.calls.filter((call) => call.method === 'GET' && /^\/v1\/invoices\/in_/.test(call.path)).length, 25) +}) + +test('ladeStripeRechnungen: unmögliche Rechnungs-IDs gehen nie an Stripe', async () => { + await listInvoices() + st.calls.length = 0 + // Pfadzeichen, ohne Präfix, kein Text, nur das Präfix, zu lang (V1: 1 bis 33 Zeichen nach „in_“) + const result = await ladeStripeRechnungen([pr('../customers'), pr('x'), pr(undefined), pr('in_'), pr(`in_${'x'.repeat(34)}`)]) + assert.equal(result.ok, true) + assert.equal(result.nachId.size, 0) + assert.equal(st.calls.length, 0) +})