import { config } from '../../config.js' import { Query, getDocument, listDocuments } from '../appwriteAdmin.js' import { getStripe } from '../stripeBilling.js' import { berlinDate } from '../finance/dates.js' import { listInvoices, mapInvoice } from '../finance/stripeFinance.js' import { kindLabel } from '../projectKinds.js' import { KUNDENARTEN, STUNDENSATZ_STANDARD_CENTS, VEREINBARTE_STATUS } from './konstanten.js' import { AuftragError } from './fehler.js' import { auftraegeAktiv } from './schalter.js' import { auftragFuerTeam } from './auftragSicht.js' import { listAuftraege, juengsteAufwandAb } from './auftragStore.js' import { listProjektRechnungen } from './projektRechnungenStore.js' import { abgerechneteArbeitsblaetter, abrechenbar, ladeArbeitsblaetter, zeitquelleFuerProjekt } from './arbeitsblaetter.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 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 = juengsteAufwandAb(gueltig) 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() } } } /** Dokument lesen; unbekannt -> AuftragError 404 " nicht gefunden." */ async function ladeDokument(collection, id, was) { try { return await getDocument(collection, id) } catch (err) { if (err?.status === 404) throw new AuftragError(404, `${was} nicht gefunden.`) throw err } } const kundeAnsicht = (doc) => ({ id: doc.$id, name: String(doc.companyName || '').trim() || String(doc.name || '').trim(), kundenart: KUNDENARTEN.includes(doc.kundenart) ? doc.kundenart : '', }) /** Ticket-Dokumente (fuer die Ticketnummer woid) zu den IDs, in Bloecken zu 100. */ async function ladeTickets(ids) { const nachId = new Map() for (let i = 0; i < ids.length; i += 100) { const seite = await listDocuments(config.collections.workorders, [Query.equal('$id', ids.slice(i, i + 100)), Query.limit(100)]) for (const ticket of seite) nachId.set(ticket.$id, ticket) } return nachId } /** * Tickets, Arbeitsblaetter (je Ticket gruppiert) und das Abgerechnete des Kunden fuer alle Zeitquellen auf einmal: je * Anfrage einmal laden, egal wie viele Projekte. Ohne Ticket wird nichts abgefragt. */ async function ladeZeitDaten(kundenId, ticketIds) { if (ticketIds.length === 0) return { tickets: new Map(), nachTicket: new Map(), abgerechnet: new Map() } const [tickets, blaetter, abgerechnet] = await Promise.all([ ladeTickets(ticketIds), ladeArbeitsblaetter(ticketIds), abgerechneteArbeitsblaetter(kundenId), ]) const nachTicket = new Map() for (const blatt of blaetter) { if (!nachTicket.has(blatt.ticketId)) nachTicket.set(blatt.ticketId, []) nachTicket.get(blatt.ticketId).push(blatt) } return { tickets, nachTicket, abgerechnet } } /** Zeitquelle des Projekts ohne Doppelte (Vereinigung der gueltigen ABs, sonst das Projektticket; siehe arbeitsblaetter.js). */ const zeitquelleVon = (projekt, abs) => [...new Set(zeitquelleFuerProjekt(projekt, abs))] /** * Ein Projektkonto (V3). abs: ABs des Projekts; prs: aktive Projektrechnungen des Projekts; stripeDaten: Ergebnis von * ladeStripeRechnungen; zeit: Ergebnis von ladeZeitDaten. Liefert zusaetzlich die Rechnungen und Blaetter fuer das Detail. */ function baueKonto(projekt, { abs, zeitquelle, prs, stripeDaten, zeit }) { const rechnungen = prs .map((eintrag) => ({ art: eintrag.art, auftragId: eintrag.auftragId, invoice: stripeDaten.nachId.get(eintrag.invoiceId) })) .filter((eintrag) => eintrag.invoice) const blaetter = zeitquelle.flatMap((id) => zeit.nachTicket.get(id) ?? []) return { konto: { projektId: projekt.$id, name: projekt.projectName || '', projektTyp: kindLabel(projekt.kind), tickets: zeitquelle.map((id) => ({ id, woid: zeit.tickets.get(id)?.woid || '' })), zeitquelle, ...rechneKonto({ auftraege: abs, rechnungen, blaetter, abgerechnet: zeit.abgerechnet, stripeOk: stripeDaten.ok }), }, rechnungen, blaetter, } } /** * Projektkonten eines Kunden (V4 "L1"): je sichtbarem Projekt ein Konto, dazu die Zeile "Ohne Projekt". Sichtbar sind alle * Projekte des Kunden ausser Vorlagen; archivierte nur mit Geldbezug (aktive Projektrechnung oder AB mit Status aus * VEREINBARTE_STATUS). Ist der AB-Schalter fuer den Kunden aus, werden ABs nicht gelesen (Vereinbart: keine AB). */ export async function projektkontenFuerKunde(kundenId) { const kundeDoc = await ladeDokument(config.collections.customers, kundenId, 'Kunde') const aktiv = auftraegeAktiv(kundeDoc) const [projekte, auftraege, prs] = await Promise.all([ listDocuments(config.collections.websiteProjects, [Query.equal('customerId', kundenId), Query.orderDesc('$createdAt'), Query.limit(1000)]), aktiv ? listAuftraege({ kundenId }) : [], listProjektRechnungen({ kundenId }), ]) const stripeDaten = await ladeStripeRechnungen(prs) const mitGeld = new Set([ ...prs.map((eintrag) => eintrag.projektId), ...auftraege.filter((auftrag) => VEREINBARTE_STATUS.includes(auftrag.status)).map((auftrag) => auftrag.projektId), ]) const sichtbar = projekte.filter((projekt) => projekt.isTemplate !== true && (!projekt.archived || mitGeld.has(projekt.$id))) const eintraege = sichtbar.map((projekt) => { const abs = auftraege.filter((auftrag) => auftrag.projektId === projekt.$id) return { projekt, abs, zeitquelle: zeitquelleVon(projekt, abs) } }) const zeit = await ladeZeitDaten(kundenId, [...new Set(eintraege.flatMap((eintrag) => eintrag.zeitquelle))]) const konten = eintraege.map( ({ projekt, abs, zeitquelle }) => baueKonto(projekt, { abs, zeitquelle, prs: prs.filter((eintrag) => eintrag.projektId === projekt.$id), stripeDaten, zeit }).konto ) // Ohne Projekt: finalisierte Projekt-Rechnungen des Kunden, die kein angezeigtes Konto beansprucht (keine Abo-Rechnungen) const kandidaten = new Map() for (const invoice of stripeDaten.alle) { if (invoice.webklarCustomerId === kundenId && invoice.source === 'projekt') kandidaten.set(invoice.id, invoice) } for (const eintrag of prs) { const invoice = stripeDaten.nachId.get(eintrag.invoiceId) if (invoice) kandidaten.set(invoice.id, invoice) } const angezeigt = new Set(sichtbar.map((projekt) => projekt.$id)) const zugeordnet = new Set(prs.filter((eintrag) => angezeigt.has(eintrag.projektId)).map((eintrag) => eintrag.invoiceId)) const ohneProjekt = rechneOhneProjekt([...kandidaten.values()].filter((invoice) => !zugeordnet.has(invoice.id))) return { auftraegeAktiv: aktiv, kunde: kundeAnsicht(kundeDoc), konten, ohneProjekt, stripeOk: stripeDaten.ok } } const neuesteZuerst = (a, b) => (b.createdDate ?? '').localeCompare(a.createdDate ?? '') || b.number.localeCompare(a.number) || b.id.localeCompare(a.id) /** * Ein Projektkonto mit seinen ABs, Rechnungen (aktive Projektrechnungen, soweit Stripe sie kennt) und den noch nicht * abgerechneten Arbeitsblaettern (V4 "L1"). Bei Stripe-Ausfall: keine Rechnungen, stripeOk false. */ export async function projektkontoDetail(projektId) { const projekt = await ladeDokument(config.collections.websiteProjects, projektId, 'Projekt') if (!projekt.customerId) throw new AuftragError(404, 'Projekt nicht gefunden.') const kundenId = projekt.customerId const kundeDoc = await ladeDokument(config.collections.customers, kundenId, 'Kunde') const aktiv = auftraegeAktiv(kundeDoc) const [abs, prs] = await Promise.all([ aktiv ? listAuftraege({ kundenId, projektId }) : [], listProjektRechnungen({ kundenId, projektId }), ]) const stripeDaten = await ladeStripeRechnungen(prs) const zeitquelle = zeitquelleVon(projekt, abs) const zeit = await ladeZeitDaten(kundenId, zeitquelle) const { konto, rechnungen, blaetter } = baueKonto(projekt, { abs, zeitquelle, prs, stripeDaten, zeit }) return { konto, auftraege: abs.map((auftrag) => auftragFuerTeam(auftrag)), rechnungen: rechnungen .map(({ art, auftragId, invoice }) => ({ id: invoice.id, number: invoice.number, status: invoice.status, createdDate: invoice.createdDate, totalCents: invoice.totalCents, paidCents: invoice.paidCents, art, auftragId, })) .sort(neuesteZuerst), offeneArbeitsblaetter: blaetter .filter((blatt) => abrechenbar(blatt) && !zeit.abgerechnet.has(blatt.id)) .sort((a, b) => a.datum.localeCompare(b.datum) || a.id.localeCompare(b.id)), stripeOk: stripeDaten.ok, } } /** * Abzeichen "abgerechnet" an den Arbeitsblaettern eines Tickets (alle Mitarbeiter, ohne Betraege): { Blatt-ID: Rechnungsnummer * oder 'Entwurf' }. Die Blatt-IDs kommen aus dem Ticket, die Zuordnung aus den aktiven Projektrechnungen seines Kunden. */ export async function abgerechnetFuerTicket(ticketId) { let ticket try { ticket = await getDocument(config.collections.workorders, ticketId) } catch (err) { if (err?.status === 404) return {} throw err } if (!ticket.customerId) return {} const abgerechnet = await abgerechneteArbeitsblaetter(ticket.customerId) if (abgerechnet.size === 0) return {} const ergebnis = {} for (const blatt of await ladeArbeitsblaetter([ticketId])) { const eintrag = abgerechnet.get(blatt.id) if (eintrag) ergebnis[blatt.id] = eintrag.rechnungsnummer || 'Entwurf' } return ergebnis }