ZUGFeRD-Rechnungsmodul (Factur-X, EN 16931) #16

Closed
opened 2026-06-20 17:23:52 +02:00 by niboer · 2 comments
Owner

Generierung strukturierter XML-Rechnungen nach EN 16931 mit lückenlosen Rechnungsnummern per DB-Sequenz. Siehe docs/design/02-datenmodell.md (Rechnungsgenerierung).

Generierung strukturierter XML-Rechnungen nach EN 16931 mit lückenlosen Rechnungsnummern per DB-Sequenz. Siehe docs/design/02-datenmodell.md (Rechnungsgenerierung).
Author
Owner

Gesamtplan ZUGFeRD-Rechnungsmodul

Abhaengigkeit: Voraussetzung

Vor diesem Issue muss ein Refaktor laufen:

contributionEuro (Decimal) → contributionCents (Int)

  • Prisma: Decimal @default(0.00) @db.Decimal(10, 2)Int @default(0)
  • DB-Migration: Umrechnung bestehender Werte * 100, Spalten-Umbenennung
  • Backend: calculateContribution() gibt Cents zurueck, alle contributionEurocontributionCents
  • Frontend: Anzeige via (cents / 100).toLocaleString(...) statt Direktformatierung
  • OpenAPI + generated.ts: Typ von number/float auf integer

(Der Refaktor wird als separater Commit vor Phase 2 durchgefuehrt.)


Phase 1: Backend — Datenmodell

1.1 Prisma: Invoice-Model anpassen

model Invoice {
  invoiceId      String   @id @default(uuid()) @db.Uuid
  invoiceNumber  String   @unique
  eventId        String?  @db.Uuid
  personId       String   @db.Uuid
  groupId        String?  @db.Uuid
  grossAmount    Int                            // Cents
  issuedAt       DateTime @default(now())
  xmlPayload     String                         // AES-256-GCM encrypted

  event  Event?  @relation(fields: [eventId], references: [eventId], onDelete: SetNull)
  person Person  @relation(fields: [personId], references: [personId], onDelete: Restrict)
  group  Group?  @relation(fields: [groupId], references: [groupId], onDelete: SetNull)

  @@map("invoices")
}

Aenderungen gegenuber heute:

  • eventId nullable + onDelete: SetNull — Force-Delete des Events blockiert nicht
  • groupId neu (optional) — Haushaltsrechnungen
  • grossAmount von Decimal auf Int (Cents) — konsistent zu FormFieldOption.price
  • xmlPayload wird per encrypt()/decrypt() aus crypto.ts verschluesselt

1.2 Neues Model: InvoiceJob

model InvoiceJob {
  jobId        String   @id @default(uuid()) @db.Uuid
  eventId      String   @db.Uuid
  startedBy    String   @db.Uuid
  status       String   @default("pending")   // pending | running | completed | failed
  totalGuests  Int      @default(0)
  processed    Int      @default(0)
  errorMessage String?
  createdAt    DateTime @default(now())
  completedAt  DateTime?

  event  Event  @relation(fields: [eventId], references: [eventId])
  person Person @relation(fields: [startedBy], references: [personId])

  @@map("invoiceJobs")
}

1.3 DB-Sequenz fuer Rechnungsnummern

CREATE SEQUENCE IF NOT EXISTS invoice_number_seq START 1;

Format: RE-{YYYY}-{5-stellig}

const seqResult = await prisma.$queryRawUnsafe<[{nextval: bigint}]>(
  "SELECT nextval('invoice_number_seq') AS nextval"
);
const year = new Date().getFullYear();
const invoiceNumber = `RE-${year}-${String(seqResult[0].nextval).padStart(5, '0')}`;

Phase 2: Backend — Invoice-Endpunkte

Endpunkt Auth Beschreibung
POST /api/v1/events/{eventId}/invoice-run requireOrganizerOrEventAdmin Massenlauf als async-Job; Response 202 { jobId }
POST /api/v1/events/{eventId}/guests/{personId}/invoice requireOrganizerOrEventAdmin Einzelrechnung fuer Person (Action-Menu)
POST /api/v1/events/{eventId}/groups/{groupId}/invoice requireOrganizerOrEventAdmin Einzelrechnung fuer Haushalt (Action-Menu)
GET /api/v1/jobs/{jobId} requireOrganizerOrEventAdmin Job-Status fuer Frontend-Polling
GET /api/v1/events/{eventId}/invoices requireOrganizerOrEventAdmin Alle Rechnungen des Events
GET /api/v1/invoices requireAuth Self-Service: filtert nach personId=$me oder Mitglied in groupId
GET /api/v1/invoices/{invoiceId}/download requireAuth + Self/GroupMember/Organizer PDF mit eingebettetem ZUGFeRD-XML

2.1 Invoice-Run als async-Job

Flow analog zum bestehenden Muster in deletion-requests.ts (setImmediate + 202 Accepted):

  1. POST /api/v1/events/{eventId}/invoice-run

    • INSERT InvoiceJob (status: pending)
    • Response sofort 202 { jobId }
    • setImmediate() startet processInvoiceJob(jobId)
  2. processInvoiceJob(jobId) im Hintergrund:

    • Status → running
    • totalGuests = Anzahl der zu verarbeitenden Gaeste setzen
    • Filter: eventGuests mit status=confirmed, paymentStatus=open, contributionCents>0
    • GroupBy groupId:
      • Haushalt vorhanden: Summe contributionCents aller Mitglieder → eine Rechnung; personId = invoiceRecipient (siehe separates Issue) oder group.createdBy
      • Kein Haushalt: Eigene Rechnung mit personId
    • Pro Rechnung: nextval holen, ZUGFeRD-XML generieren, encrypt(), INSERT Invoice, processed++
    • Nach Ende: Status → completed
    • Bei Fehler: Status → failed, errorMessage setzen
  3. Frontend pollt GET /api/v1/jobs/{jobId}, zeigt nach completed die Ergebnisse

2.2 ZUGFeRD/PDF-Generierung

backend/src/lib/invoice-generator.ts

  • Seller: Event-Organisator (events.createdBypersons + contactData)
  • Buyer: personId aus Invoice → persons + contactData
  • Items: "Teilnahme an [EventTitle]" + Betrag in Cents
  • VAT: 0.00 (privat, keine MwSt-Pflicht)
  • Output: XML-String → encrypt(xmlPayload) → DB

PDF-Generierung via pdf-lib:

  • Einfaches Rechnungslayout: Absender, Empfaenger, Datum, Positionen, Summe
  • factur-x.xml als Embedded File (ZUGFeRD-Konformitaet)
  • Output: Buffer → Content-Type: application/pdf

Phase 3: Frontend

3.1 Payment-Tab (/app/events/[eventId])

  • Button "Rechnungslauf starten" (oben, nur Organizer/Event-Admin)
    • Bestaetigungsdialog: "Fuer alle bestaetigten Gaeste mit offenem Betrag?"
    • Startet Job → Polling → Ergebnisliste
  • Action-Menue pro Gast-Zeile:
    • "Rechnung erstellen" (einzeln, Gast oder dessen Haushalt)
    • "Rechnung herunterladen" (nur wenn vorhanden)
  • Status-Spalte "Rechnung" mit Icon (vorhanden/nicht vorhanden)
  • Aggregierte Anzeige: Summe aller Rechnungen, Anzahl erstellt/ausstehend

3.2 Self-Service (/app/events/[eventId], Gast-Ansicht)

  • Bereich "Meine Rechnungen"
  • Listet Rechnungen aus GET /api/v1/invoices?eventId=X
  • Download-Button pro Rechnung
  • Bei Haushaltsrechnungen: jeder Haushaltsmitglied sieht + downloadet

Phase 4: Autorisierung

Endpunkt Organizer Event-Admin Haushaltsmitglied Gast selbst
POST invoice-run
POST /guests/{id}/invoice
POST /groups/{id}/invoice
GET /jobs/{id}
GET /events/{id}/invoices
GET /invoices (Self)
GET /invoices/{id}/download

Phase 5: OpenAPI & Tests

  • Invoice-Schema um groupId ergaenzen
  • Neue Schemas: InvoiceJob, InvoiceRunResponse, CreateInvoice, InvoiceDownload
  • Alle Endpunkte in openapi.yaml deklarieren
  • Unit-Tests pro Endpunkt (analog bestehendes Pattern in __tests__/)
  • Integrationstests: Invoice-Run-Job durchlaeuft vollstaendig

Phase 6: Schwaache Abhaengigkeit (E-Mail)

Sobald ein Backend-E-Mail-Versand existiert (separater Issue, noch nicht angelegt), sollen Rechnungen automatisch als PDF-Anhang an den Rechnungsempfaenger gesendet werden. Bis dahin: Download-only.


Rechnungsempfaenger im Haushalt

Wurde in #253 umgesetzt. Rechnungsempfaenger kann abgefragt werden.


Dateien (neu / geaendert)

Datei Aktion
backend/prisma/schema.prisma Geaendert: Invoice-Model + InvoiceJob + Sequenz
backend/prisma/migrations/... Neu: Migration
backend/src/lib/invoice-generator.ts Neu
backend/src/routes/invoices.ts Erweitert (7 neue Endpunkte)
backend/src/__tests__/invoices.test.ts Erweitert
backend/openapi.yaml Geaendert (neue Schemas + Endpunkte)
backend/package.json + pdf-lib
frontend/src/lib/api/generated.ts Regeneriert via Orval
frontend/src/routes/app/events/[eventId]/+page.svelte Geaendert (Payment-Tab + Self-Service)
frontend/src/routes/app/events/[eventId]/+page.server.ts Geaendert (Invoice-Job-Polling)
docs/design/02-datenmodell.md Aktualisiert
## Gesamtplan ZUGFeRD-Rechnungsmodul ### Abhaengigkeit: Voraussetzung Vor diesem Issue muss ein Refaktor laufen: **`contributionEuro` (Decimal) → `contributionCents` (Int)** - Prisma: `Decimal @default(0.00) @db.Decimal(10, 2)` → `Int @default(0)` - DB-Migration: Umrechnung bestehender Werte `* 100`, Spalten-Umbenennung - Backend: `calculateContribution()` gibt Cents zurueck, alle `contributionEuro` → `contributionCents` - Frontend: Anzeige via `(cents / 100).toLocaleString(...)` statt Direktformatierung - OpenAPI + generated.ts: Typ von `number/float` auf `integer` (Der Refaktor wird als separater Commit vor Phase 2 durchgefuehrt.) --- ## Phase 1: Backend — Datenmodell ### 1.1 Prisma: `Invoice`-Model anpassen ```prisma model Invoice { invoiceId String @id @default(uuid()) @db.Uuid invoiceNumber String @unique eventId String? @db.Uuid personId String @db.Uuid groupId String? @db.Uuid grossAmount Int // Cents issuedAt DateTime @default(now()) xmlPayload String // AES-256-GCM encrypted event Event? @relation(fields: [eventId], references: [eventId], onDelete: SetNull) person Person @relation(fields: [personId], references: [personId], onDelete: Restrict) group Group? @relation(fields: [groupId], references: [groupId], onDelete: SetNull) @@map("invoices") } ``` **Aenderungen gegenuber heute:** - `eventId` nullable + `onDelete: SetNull` — Force-Delete des Events blockiert nicht - `groupId` neu (optional) — Haushaltsrechnungen - `grossAmount` von `Decimal` auf `Int` (Cents) — konsistent zu `FormFieldOption.price` - `xmlPayload` wird per `encrypt()`/`decrypt()` aus `crypto.ts` verschluesselt ### 1.2 Neues Model: `InvoiceJob` ```prisma model InvoiceJob { jobId String @id @default(uuid()) @db.Uuid eventId String @db.Uuid startedBy String @db.Uuid status String @default("pending") // pending | running | completed | failed totalGuests Int @default(0) processed Int @default(0) errorMessage String? createdAt DateTime @default(now()) completedAt DateTime? event Event @relation(fields: [eventId], references: [eventId]) person Person @relation(fields: [startedBy], references: [personId]) @@map("invoiceJobs") } ``` ### 1.3 DB-Sequenz fuer Rechnungsnummern ```sql CREATE SEQUENCE IF NOT EXISTS invoice_number_seq START 1; ``` Format: `RE-{YYYY}-{5-stellig}` ```typescript const seqResult = await prisma.$queryRawUnsafe<[{nextval: bigint}]>( "SELECT nextval('invoice_number_seq') AS nextval" ); const year = new Date().getFullYear(); const invoiceNumber = `RE-${year}-${String(seqResult[0].nextval).padStart(5, '0')}`; ``` --- ## Phase 2: Backend — Invoice-Endpunkte | Endpunkt | Auth | Beschreibung | |----------|------|-------------| | `POST /api/v1/events/{eventId}/invoice-run` | `requireOrganizerOrEventAdmin` | Massenlauf als async-Job; Response `202 { jobId }` | | `POST /api/v1/events/{eventId}/guests/{personId}/invoice` | `requireOrganizerOrEventAdmin` | Einzelrechnung fuer Person (Action-Menu) | | `POST /api/v1/events/{eventId}/groups/{groupId}/invoice` | `requireOrganizerOrEventAdmin` | Einzelrechnung fuer Haushalt (Action-Menu) | | `GET /api/v1/jobs/{jobId}` | `requireOrganizerOrEventAdmin` | Job-Status fuer Frontend-Polling | | `GET /api/v1/events/{eventId}/invoices` | `requireOrganizerOrEventAdmin` | Alle Rechnungen des Events | | `GET /api/v1/invoices` | `requireAuth` | Self-Service: filtert nach `personId=$me` oder Mitglied in `groupId` | | `GET /api/v1/invoices/{invoiceId}/download` | `requireAuth` + Self/GroupMember/Organizer | PDF mit eingebettetem ZUGFeRD-XML | ### 2.1 Invoice-Run als async-Job Flow analog zum bestehenden Muster in `deletion-requests.ts` (`setImmediate` + `202 Accepted`): 1. `POST /api/v1/events/{eventId}/invoice-run` - INSERT `InvoiceJob` (status: `pending`) - Response sofort `202 { jobId }` - `setImmediate()` startet `processInvoiceJob(jobId)` 2. `processInvoiceJob(jobId)` im Hintergrund: - Status → `running` - `totalGuests` = Anzahl der zu verarbeitenden Gaeste setzen - Filter: `eventGuests` mit `status=confirmed`, `paymentStatus=open`, `contributionCents>0` - GroupBy `groupId`: - **Haushalt vorhanden:** Summe `contributionCents` aller Mitglieder → eine Rechnung; `personId` = invoiceRecipient (siehe separates Issue) oder `group.createdBy` - **Kein Haushalt:** Eigene Rechnung mit `personId` - Pro Rechnung: `nextval` holen, ZUGFeRD-XML generieren, `encrypt()`, INSERT `Invoice`, `processed++` - Nach Ende: Status → `completed` - Bei Fehler: Status → `failed`, `errorMessage` setzen 3. Frontend pollt `GET /api/v1/jobs/{jobId}`, zeigt nach `completed` die Ergebnisse ### 2.2 ZUGFeRD/PDF-Generierung **`backend/src/lib/invoice-generator.ts`** - **Seller:** Event-Organisator (`events.createdBy` → `persons` + `contactData`) - **Buyer:** `personId` aus Invoice → `persons` + `contactData` - **Items:** "Teilnahme an [EventTitle]" + Betrag in Cents - **VAT:** `0.00` (privat, keine MwSt-Pflicht) - **Output:** XML-String → `encrypt(xmlPayload)` → DB **PDF-Generierung via `pdf-lib`:** - Einfaches Rechnungslayout: Absender, Empfaenger, Datum, Positionen, Summe - `factur-x.xml` als Embedded File (ZUGFeRD-Konformitaet) - Output: Buffer → `Content-Type: application/pdf` --- ## Phase 3: Frontend ### 3.1 Payment-Tab (`/app/events/[eventId]`) - Button "Rechnungslauf starten" (oben, nur Organizer/Event-Admin) - Bestaetigungsdialog: "Fuer alle bestaetigten Gaeste mit offenem Betrag?" - Startet Job → Polling → Ergebnisliste - Action-Menue pro Gast-Zeile: - "Rechnung erstellen" (einzeln, Gast oder dessen Haushalt) - "Rechnung herunterladen" (nur wenn vorhanden) - Status-Spalte "Rechnung" mit Icon (vorhanden/nicht vorhanden) - Aggregierte Anzeige: Summe aller Rechnungen, Anzahl erstellt/ausstehend ### 3.2 Self-Service (`/app/events/[eventId]`, Gast-Ansicht) - Bereich "Meine Rechnungen" - Listet Rechnungen aus `GET /api/v1/invoices?eventId=X` - Download-Button pro Rechnung - Bei Haushaltsrechnungen: jeder Haushaltsmitglied sieht + downloadet --- ## Phase 4: Autorisierung | Endpunkt | Organizer | Event-Admin | Haushaltsmitglied | Gast selbst | |----------|-----------|-------------|-------------------|-------------| | `POST invoice-run` | ✅ | ✅ | — | — | | `POST /guests/{id}/invoice` | ✅ | ✅ | — | — | | `POST /groups/{id}/invoice` | ✅ | ✅ | — | — | | `GET /jobs/{id}` | ✅ | ✅ | — | — | | `GET /events/{id}/invoices` | ✅ | ✅ | — | — | | `GET /invoices` (Self) | — | — | ✅ | ✅ | | `GET /invoices/{id}/download` | ✅ | ✅ | ✅ | ✅ | --- ## Phase 5: OpenAPI & Tests - `Invoice`-Schema um `groupId` ergaenzen - Neue Schemas: `InvoiceJob`, `InvoiceRunResponse`, `CreateInvoice`, `InvoiceDownload` - Alle Endpunkte in `openapi.yaml` deklarieren - Unit-Tests pro Endpunkt (analog bestehendes Pattern in `__tests__/`) - Integrationstests: Invoice-Run-Job durchlaeuft vollstaendig --- ## Phase 6: Schwaache Abhaengigkeit (E-Mail) Sobald ein Backend-E-Mail-Versand existiert (separater Issue, noch nicht angelegt), sollen Rechnungen automatisch als PDF-Anhang an den Rechnungsempfaenger gesendet werden. Bis dahin: **Download-only**. --- ## Rechnungsempfaenger im Haushalt Wurde in #253 umgesetzt. Rechnungsempfaenger kann abgefragt werden. --- ## Dateien (neu / geaendert) | Datei | Aktion | |-------|--------| | `backend/prisma/schema.prisma` | Geaendert: Invoice-Model + InvoiceJob + Sequenz | | `backend/prisma/migrations/...` | Neu: Migration | | `backend/src/lib/invoice-generator.ts` | Neu | | `backend/src/routes/invoices.ts` | Erweitert (7 neue Endpunkte) | | `backend/src/__tests__/invoices.test.ts` | Erweitert | | `backend/openapi.yaml` | Geaendert (neue Schemas + Endpunkte) | | `backend/package.json` | + `pdf-lib` | | `frontend/src/lib/api/generated.ts` | Regeneriert via Orval | | `frontend/src/routes/app/events/[eventId]/+page.svelte` | Geaendert (Payment-Tab + Self-Service) | | `frontend/src/routes/app/events/[eventId]/+page.server.ts` | Geaendert (Invoice-Job-Polling) | | `docs/design/02-datenmodell.md` | Aktualisiert |
Author
Owner

Nachtrag: Separater Issue #216 fuer Rechnungsempfaenger im Haushalt wurde angelegt.

**Nachtrag:** Separater Issue #216 fuer Rechnungsempfaenger im Haushalt wurde angelegt.
Sign in to join this conversation.
No description provided.