Rechnungen: Anzahlung und Schlussrechnung bauen (Minuszeilen, Entwurfsschutz, Selbstheilung)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
11531cacb6
commit
4f8e956574
2 files changed
+256
-11
No files matched your search
@@ -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 })
|
||||
}
|
||||
Reference in new issue
Block a user