Rechnungen: Anzahlung und Schlussrechnung bauen (Minuszeilen, Entwurfsschutz, Selbstheilung)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:15:25 +00:00
1 parent 11531cacb6
commit 4f8e956574
2 files changed
+256 -11

No files matched your search

+101 -11
View File
@@ -9,18 +9,20 @@ import { getStripe } from '../stripeBilling.js'
import { withLocks } from '../keyedLock.js'
import { AuftragError } from './fehler.js'
import {
RECHNUNGS_ARTEN, LIMITS, VEREINBARTE_STATUS, STUNDENSATZ_STANDARD_CENTS, SCHALTER_AUS_MELDUNG, sperreAuftrag,
RECHNUNGS_ARTEN, AB_RECHNUNGS_ARTEN, LIMITS, VEREINBARTE_STATUS, STUNDENSATZ_STANDARD_CENTS, SCHALTER_AUS_MELDUNG, sperreAuftrag,
} from './konstanten.js'
import {
festePositionen, zusatzPositionen, pruefePositionen, kuerzen, auftragDatum, aufwandPositionen, leistungszeitraumText, SCHLUSS_GRENZEN,
festePositionen, zusatzPositionen, pruefePositionen, kuerzen, auftragDatum, aufwandPositionen, leistungszeitraumText,
anzahlungPosition, schlussPositionen, SCHLUSS_GRENZEN,
} from './positionen.js'
import { auftraegeAktiv } from './schalter.js'
import { getAuftrag, listAuftraege, updateAuftrag } from './auftragStore.js'
import { updateProjektRechnung } from './projektRechnungenStore.js'
import { listProjektRechnungen, updateProjektRechnung } from './projektRechnungenStore.js'
import {
ladeArbeitsblaetter, ladeArbeitsblaetterNachIds, abrechenbar, abgerechneteArbeitsblaetter, offeneArbeitsblaetter, zeitquelleFuerProjekt,
} from './arbeitsblaetter.js'
import { formatDe } from '../finance/dates.js'
import { berlinDate, formatDe } from '../finance/dates.js'
import { euroDe } from '../finance/format.js'
// Appwrite-Dokument-ID
const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
@@ -40,18 +42,25 @@ export async function ladeFestenKunden(customerId) {
return kunde
}
/** Projekt des Kunden; fremde und unbekannte Projekte sind nicht zu unterscheiden (404). */
async function ladeProjekt(kundenId, projektId) {
if (typeof projektId !== 'string' || !projektId) throw new AuftragError(400, 'Für diese Rechnungsart ist ein Projekt nötig.')
if (!KENNUNG.test(projektId)) throw new AuftragError(404, 'Projekt nicht gefunden.')
/**
* Projekt des Kunden; fremde und unbekannte Projekte sind nicht zu unterscheiden (404). Mit weich = true liefert jeder
* Fehler stattdessen null (nur Lesefehler von Appwrite werden weitergereicht).
*/
async function ladeProjekt(kundenId, projektId, { weich = false } = {}) {
const fehler = (status, meldung) => {
if (weich) return null
throw new AuftragError(status, meldung)
}
if (typeof projektId !== 'string' || !projektId) return fehler(400, 'Für diese Rechnungsart ist ein Projekt nötig.')
if (!KENNUNG.test(projektId)) return fehler(404, 'Projekt nicht gefunden.')
let projekt
try {
projekt = await getDocument(config.collections.websiteProjects, projektId)
} catch (err) {
if (err.status === 404) throw new AuftragError(404, 'Projekt nicht gefunden.')
if (err.status === 404) return fehler(404, 'Projekt nicht gefunden.')
throw err
}
if (projekt.customerId !== kundenId) throw new AuftragError(404, 'Projekt nicht gefunden.')
if (projekt.customerId !== kundenId) return fehler(404, 'Projekt nicht gefunden.')
return { id: projekt.$id, name: projekt.projectName || projekt.subdomain || 'Projekt', ticketId: projekt.ticketId || '' }
}
@@ -175,6 +184,7 @@ export async function baueRechnung(eingabe) {
const art = eingabe.art ?? 'frei'
if (typeof art !== 'string' || !RECHNUNGS_ARTEN.includes(art)) throw new AuftragError(400, 'Unbekannte Rechnungsart.')
const kunde = await ladeFestenKunden(eingabe.customerId)
if (AB_RECHNUNGS_ARTEN.includes(art) && !auftraegeAktiv(kunde)) throw new AuftragError(400, SCHALTER_AUS_MELDUNG)
const leistungszeitraum = pruefeLeistungszeitraum(eingabe.leistungszeitraum)
const basis = { kunde, art, leistungszeitraum, createdBy: eingabe.createdBy }
if (art === 'frei') return schliesseAb({ ...basis, positionen: festePositionen(eingabe.items) })
@@ -183,7 +193,7 @@ export async function baueRechnung(eingabe) {
return schliesseAb({ ...basis, projekt, positionen: festePositionen(eingabe.items) })
}
if (art === 'aufwand') return baueAufwand(basis, eingabe)
throw new AuftragError(501, 'Rechnungsart noch nicht umgesetzt.')
return baueAbRechnung(basis, eingabe)
}
const MAX_BLAETTER = 400
@@ -282,3 +292,83 @@ async function baueAufwand(basis, eingabe) {
arbeitsblattIds: blaetter.map((b) => b.id),
})
}
const FINALISIERT = ['open', 'paid', 'uncollectible']
/**
* Fruehere Projektrechnungen einer AB (aktive Datensaetze) mit dem Stand bei Stripe. Selbstheilung: eine im Dashboard
* stornierte oder geloeschte Rechnung wird freigegeben (bei einer Schlussrechnung geht die AB zurueck) und uebersprungen.
*/
async function ladeFruehere(auftragId) {
const ergebnis = []
for (const doc of await listProjektRechnungen({ auftragId })) {
let roh
try {
roh = await getStripe().invoices.retrieve(doc.invoiceId)
} catch (err) {
if (!fehltBeiStripe(err)) throw err
roh = null
}
if (!roh || roh.status === 'void') {
await buche('Rechnung freigeben', () => gibProjektRechnungFrei(doc.invoiceId))
continue
}
const nummer = roh.number || ''
if (FINALISIERT.includes(roh.status) && nummer && !doc.rechnungsnummer) {
await buche('Rechnungsnummer nachtragen', () => updateProjektRechnung(doc.invoiceId, { rechnungsnummer: nummer }))
}
ergebnis.push({
invoiceId: doc.invoiceId,
art: doc.art,
status: roh.status,
rechnungsnummer: nummer,
datum: berlinDate(new Date((roh.status_transitions?.finalized_at ?? roh.created) * 1000)),
betragCents: roh.total ?? 0,
})
}
return ergebnis
}
/** Anzahlung und Schlussrechnung zu einer Festpreis-AB. */
async function baueAbRechnung(basis, eingabe) {
const { kunde, art } = basis
const { auftragId } = eingabe
if (typeof auftragId !== 'string' || !auftragId) throw new AuftragError(400, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.')
let auftrag = await ladeAuftrag(kunde.$id, auftragId)
if (auftrag.art !== 'festpreis') throw new AuftragError(400, 'Anzahlung und Schlussrechnung gehen nur mit einer Festpreis-Auftragsbestätigung.')
if (auftrag.status === 'abgerechnet') {
await ladeFruehere(auftrag.id) // gibt eine im Dashboard stornierte Schlussrechnung frei und setzt die AB zurueck
auftrag = await ladeAuftrag(kunde.$id, auftragId)
}
if (!['versendet', 'bestaetigt'].includes(auftrag.status)) throw new AuftragError(400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.')
let projekt = null
if (typeof eingabe.projektId === 'string' && eingabe.projektId) {
projekt = await ladeProjekt(kunde.$id, eingabe.projektId)
if (auftrag.projektId && auftrag.projektId !== projekt.id) throw new AuftragError(400, 'Der Auftrag gehört nicht zu diesem Projekt.')
} else if (auftrag.projektId) {
projekt = await ladeProjekt(kunde.$id, auftrag.projektId, { weich: true })
}
const fruehere = await ladeFruehere(auftrag.id)
const entwurf = fruehere.find((f) => f.status === 'draft' && (art === 'schluss' || f.art === 'schluss'))
if (entwurf) {
throw new AuftragError(409, art === 'schluss'
? `Erst den Entwurf über ${euroDe(entwurf.betragCents)} senden oder löschen.`
: 'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf der Schlussrechnung. Erst den Entwurf senden oder löschen.')
}
const finalisierte = fruehere.filter((f) => FINALISIERT.includes(f.status))
let positionen
if (art === 'anzahlung') {
const angaben = eingabe.anzahlung ?? {}
if (typeof angaben !== 'object' || Array.isArray(angaben)) throw new AuftragError(400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.')
positionen = [anzahlungPosition(auftrag, angaben, finalisierte.reduce((s, f) => s + f.betragCents, 0))]
} else {
positionen = schlussPositionen(
auftrag,
finalisierte.map(({ invoiceId, rechnungsnummer, datum, betragCents, art: fArt }) => ({ invoiceId, rechnungsnummer, datum, betragCents, art: fArt })),
eingabe.items
)
}
return schliesseAb({ ...basis, projekt, auftrag, positionen })
}