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 * dazu. Alles hier liest nur, weder Appwrite noch Stripe werden geschrieben. */ // Rechnungsstatus laut mapInvoice (stripeFinance.js), die als "berechnet" zaehlen; Entwurf und storniert nie const FINALISIERT = new Set(['offen', 'ueberfaellig', 'bezahlt', 'uneinbringlich']) const summe = (liste, feld) => liste.reduce((s, eintrag) => s + eintrag[feld], 0) const zeitpunkt = (auftrag) => new Date(auftrag.gesendetAm || auftrag.erstelltAm || 0).getTime() || 0 const juengsteZuerst = (a, b) => zeitpunkt(b) - zeitpunkt(a) || String(b.id).localeCompare(String(a.id)) const zeitWert = (minuten, satzCents) => Math.round((minuten * satzCents) / 60) // Wert der Zeit = Summe der je Berliner Tag gerundeten Werte (Karte: Tag -> Minuten), genau wie die Zeilen einer Aufwandsrechnung // (aufwandPositionen in L2: eine Zeile je Tag mit Math.round(Minuten × Satz / 60)); so laufen Konto und Rechnung nicht um Cent auseinander const tageswert = (minutenJeTag, satzCents) => [...minutenJeTag.values()].reduce((s, minuten) => s + zeitWert(minuten, satzCents), 0) const addiere = (karte, tag, minuten) => karte.set(tag, (karte.get(tag) ?? 0) + minuten) /** * Die Zahlen eines Projekts. auftraege: alle ABs des Projekts (jeder Status); rechnungen: [{ art, auftragId, invoice }] mit * der Rechnung in mapInvoice-Form (nur aktive Projektrechnungen, deren Rechnung gefunden wurde); blaetter: Arbeitsblaetter der * Zeitquelle (datum = Berliner Tag); abgerechnet: Map oder Set der Blatt-IDs aus aktiven Projektrechnungen (auch Entwuerfe); * stripeOk false = die Rechnungen sind unbekannt (berechnet und bezahlt 0, offen null). */ export function rechneKonto({ auftraege = [], rechnungen = [], blaetter = [], abgerechnet = new Map(), stripeOk = true } = {}) { const gueltig = auftraege.filter((auftrag) => VEREINBARTE_STATUS.includes(auftrag.status)) const festpreise = gueltig.filter((auftrag) => auftrag.art === 'festpreis') const festpreisIds = new Set(festpreise.map((auftrag) => auftrag.id)) const festpreisCents = festpreise.length ? festpreise.reduce((s, auftrag) => s + (Number(auftrag.summeCents) || 0), 0) : null const aufwandAb = gueltig.filter((auftrag) => auftrag.art === 'aufwand').sort(juengsteZuerst)[0] ?? null const aufwand = aufwandAb ? { stundensatzCents: aufwandAb.stundensatzCents > 0 ? aufwandAb.stundensatzCents : STUNDENSATZ_STANDARD_CENTS, schaetzungMinuten: aufwandAb.schaetzungMinuten ?? null, } : null const satzCents = aufwand ? aufwand.stundensatzCents : STUNDENSATZ_STANDARD_CENTS let berechnetCents = 0 let bezahltCents = 0 let aufFestpreisCents = 0 if (stripeOk) { for (const { invoice, auftragId } of rechnungen) { if (!FINALISIERT.has(invoice.status)) continue berechnetCents += invoice.totalCents bezahltCents += invoice.paidCents // nur Rechnungen zu einer als vereinbart zaehlenden Festpreis-AB mindern das Offene if (festpreisIds.has(auftragId)) aufFestpreisCents += invoice.totalCents } } const offenCents = festpreisCents === null || !stripeOk ? null : Math.max(0, festpreisCents - aufFestpreisCents) let minuten = 0 let aboMinuten = 0 let offeneMinuten = 0 const minutenJeTag = new Map() // alle abrechenbaren Blaetter (auch schon berechnete) const offeneMinutenJeTag = new Map() // nur die offenen; ein Blatt ohne Datum (nur theoretisch) bildet einen eigenen Tag '' for (const blatt of blaetter) { if (abrechenbar(blatt)) { minuten += blatt.minuten addiere(minutenJeTag, blatt.datum, blatt.minuten) if (!abgerechnet.has(blatt.id)) { offeneMinuten += blatt.minuten addiere(offeneMinutenJeTag, blatt.datum, blatt.minuten) } } else if (blatt.aboZeit && !blatt.kommentar) { aboMinuten += blatt.minuten } } return { vereinbart: { festpreisCents, aufwand }, berechnetCents, bezahltCents, offenCents, zeit: { minuten, aboMinuten, offeneMinuten, satzCents, wertCents: tageswert(minutenJeTag, satzCents), offenerWertCents: tageswert(offeneMinutenJeTag, satzCents), }, } } /** Zeile "Ohne Projekt": Rechnungen in mapInvoice-Form; gezaehlt werden nur finalisierte. */ 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() } } }