Compare commits

...
2 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 4.8 8169c74d8e Vertrag: Auto-CANCELLED bei Kuendigungsbestaetigung + Cockpit-Filter
1) Auto-Status: Wird eine Kuendigungsbestaetigung hinzugefuegt (Datum
   ueber Formular und/oder Dokument-Upload), wird der Vertrag von ACTIVE
   auf CANCELLED gesetzt und das Vertragsende = Kuendigungsdatum.
   Zentrale Funktion maybeCancelOnCancellationConfirmation (idempotent,
   nur aus ACTIVE). Upload-Route ersetzt die alte Inline-Logik (setzt
   jetzt auch endDate); Update-Controller triggert nur bei neu/geaendertem
   Bestaetigungsdatum (manuelle Status-Korrekturen bleiben unangetastet).

2) Cockpit-Filter 'Kuendigungsbestaetigung': neue Liste
   cancellationConfirmations (Vertraege mit Bestaetigung in Status
   ACTIVE/DRAFT/CANCELLED) + Filter-Option im Cockpit-Dropdown. Eigene
   Liste, weil bereits CANCELLED-Vertraege mangels Issue sonst nicht in
   der Cockpit-Liste auftauchen.

Beides lokal verifiziert (Helper: ACTIVE->CANCELLED + endDate; Cockpit:
Vertrag erscheint in der Liste mit korrektem Status).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 15:13:05 +02:00
duffyduckandClaude Opus 4.8 5423c83009 Gutschriften Phase 3b/2: ZUGFeRD/Factur-X (hybrides PDF/A-3)
- zugferd.service.ts: CII-XML (EN 16931, urn:cen.eu:en16931:2017,
  Dokumenttyp 381 Gutschrift). Steuerkategorie S bei USt, sonst E mit
  Befreiungsgrund. Verkaeufer=Firma (CompanyProfile), Kaeufer=Kunde.
- zugferdPdf.service.ts: bettet factur-x.xml als AF /Data ein, setzt
  sRGB-OutputIntent + XMP (PDF/A-3B pdfaid + Factur-X-Extension-Schema)
  via pdf-lib.
- creditNotePdf: eingebettete DejaVuSans-Fonts (Pflicht fuer PDF/A)
  statt Standard-Helvetica; nach PDF-Erzeugung ZUGFeRD-Embedding.
- assets/fonts (DejaVuSans + Bold) + assets/icc (sRGB) ins Repo;
  Dockerfile kopiert backend/assets ins Runtime-Image.

Lokal strukturell verifiziert: 1 Seite, /AF, /Metadata, /OutputIntents,
EmbeddedFiles, Font eingebettet, XML wohlgeformt (xmllint), TypeCode
381, GrandTotal korrekt.

WICHTIG: vor Prod gegen einen ZUGFeRD-/Factur-X-Validator pruefen
(Staging + echte Firmendaten). Feinheiten (Trailer-ID, XMP, XML-MIME)
ggf. nach erstem Validator-Lauf nachziehen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-07 12:56:12 +02:00
15 changed files with 507 additions and 31 deletions
+4
View File
@@ -53,6 +53,10 @@ COPY backend/prisma ./prisma
COPY --from=backend-builder /build/backend/src ./src
COPY backend/tsconfig.json ./tsconfig.json
# Statische Assets (eingebettete Fonts für PDF/A + sRGB-ICC-Profil für den
# ZUGFeRD-OutputIntent). Werden zur Laufzeit aus process.cwd()/assets gelesen.
COPY backend/assets ./assets
# Frontend-Build ins public/-Verzeichnis (wird in production-Mode statisch ausgeliefert)
COPY --from=frontend-builder /build/frontend/dist ./public
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15 -1
View File
@@ -11,7 +11,7 @@ import { logChange } from '../services/audit.service.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate, isContractIdentifierField, validateContractIdentifier, validatePortalUsername } from '../utils/sanitize.js';
import { ApiError } from '../utils/apiError.js';
import { canAccessContract, canAccessCustomer } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
import { maybeActivateOnDeliveryConfirmation, maybeCancelOnCancellationConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
/**
* Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten
@@ -232,6 +232,20 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
const sanitizedBody = sanitizeContractBody(req.body);
const contract = await contractService.updateContract(contractId, sanitizedBody as any);
// Wenn über das Formular ein (neues/geändertes) Kündigungsbestätigungs-
// datum gesetzt wurde: Vertrag ACTIVE → CANCELLED + Vertragsende =
// Kündigungsdatum. Nur bei tatsächlicher Änderung des Datums auslösen,
// damit manuelle Status-Korrekturen nicht sofort zurückgesetzt werden.
{
const asDay = (d: unknown) =>
d ? new Date(d as string).toISOString().split('T')[0] : null;
const beforeConf = asDay((before as any)?.cancellationConfirmationDate);
const afterConf = asDay((sanitizedBody as any)?.cancellationConfirmationDate);
if (afterConf && afterConf !== beforeConf) {
await maybeCancelOnCancellationConfirmation(contractId, req);
}
}
// Geänderte Felder ermitteln
const changes: Record<string, { von: unknown; nach: unknown }> = {};
const fieldLabels: Record<string, string> = {
+5 -16
View File
@@ -14,6 +14,7 @@ import {
} from '../utils/accessControl.js';
import { validateOptionalIsoDate } from '../utils/sanitize.js';
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
import { maybeCancelOnCancellationConfirmation } from '../services/contractStatusScheduler.service.js';
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
// Permission, nicht ob die Ziel-Resource zum Caller passt. Helper-Funktion
@@ -694,23 +695,11 @@ async function handleContractDocumentUpload(
data: updateData,
});
// Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde und
// der Vertrag noch ACTIVE ist → auf CANCELLED umstellen + Audit-Log.
// Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde:
// Vertrag ACTIVE CANCELLED und Vertragsende = Kündigungsdatum.
// "Optionen" ist für Vertrags-Änderungen gedacht, nicht für echte Kündigungen.
if (fieldName === 'cancellationConfirmationPath' && contract.status === 'ACTIVE') {
await prisma.contract.update({
where: { id: contractId },
data: { status: 'CANCELLED' },
});
await logChange({
req,
action: 'UPDATE',
resourceType: 'Contract',
resourceId: contractId.toString(),
label: `Vertrag ${contract.contractNumber} automatisch auf CANCELLED gesetzt (Kündigungsbestätigung hochgeladen)`,
details: { vorher: 'ACTIVE', nachher: 'CANCELLED', trigger: 'cancellationConfirmation-Upload' },
customerId: contract.customerId,
});
if (fieldName === 'cancellationConfirmationPath') {
await maybeCancelOnCancellationConfirmation(contractId, req);
}
res.json({
@@ -102,6 +102,9 @@ export interface ReportedMeterReading {
export interface CockpitResult {
contracts: CockpitContract[];
// Verträge mit hinterlegter Kündigungsbestätigung (Dokument und/oder Datum)
// in Status ACTIVE/DRAFT/CANCELLED als eigener Filter im Cockpit.
cancellationConfirmations: CockpitContract[];
documentAlerts: DocumentAlert[];
reportedReadings: ReportedMeterReading[];
summary: CockpitSummary;
@@ -775,8 +778,55 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
// Gemeldete Zählerstände (REPORTED Status)
const reportedReadings = await getReportedMeterReadings(opts?.customerIds);
// Filter „Kündigungsbestätigung vorhanden": alle Verträge mit
// cancellationConfirmationPath und/oder -Date in Status ACTIVE/DRAFT/
// CANCELLED. Eigene Liste, weil solche Verträge (v.a. bereits CANCELLED)
// sonst mangels „Issue" nicht in der Cockpit-Liste auftauchen.
const CANCEL_CONF_STATUSES: ContractStatus[] = ['ACTIVE', 'DRAFT', 'CANCELLED'];
const cancellationConfirmations: CockpitContract[] = contracts
.filter(
(c) =>
(c.cancellationConfirmationPath || c.cancellationConfirmationDate) &&
CANCEL_CONF_STATUSES.includes(c.status),
)
.map((c) => {
const customerName =
c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`;
const dateStr = c.cancellationConfirmationDate
? new Date(c.cancellationConfirmationDate).toLocaleDateString('de-DE')
: null;
const parts: string[] = [];
if (c.cancellationConfirmationPath) parts.push('Dokument');
if (dateStr) parts.push(`Datum ${dateStr}`);
return {
id: c.id,
contractNumber: c.contractNumber,
type: c.type,
status: c.status,
customer: {
id: c.customer.id,
customerNumber: c.customer.customerNumber,
name: customerName,
},
provider: c.provider ? { id: c.provider.id, name: c.provider.name } : undefined,
tariff: c.tariff ? { id: c.tariff.id, name: c.tariff.name } : undefined,
providerName: c.providerName || undefined,
tariffName: c.tariffName || undefined,
issues: [
{
type: 'has_cancellation_confirmation',
label: 'Kündigungsbestätigung vorhanden',
urgency: 'none' as UrgencyLevel,
details: parts.join(' · ') || 'Kündigungsbestätigung hinterlegt',
},
],
highestUrgency: 'none' as UrgencyLevel,
};
});
return {
contracts: cockpitContracts,
cancellationConfirmations,
documentAlerts,
reportedReadings,
summary,
@@ -157,6 +157,70 @@ export async function withContractDocumentLock<T>(
* (SaveAttachmentModal / ContractDetail). Vergleich case-insensitive +
* getrimmt zur Robustheit.
*/
/**
* Wird aufgerufen, wenn zu einem Vertrag eine Kündigungsbestätigung
* hinzugefügt wird entweder als Datum (`cancellationConfirmationDate`,
* z.B. über das Vertragsformular) und/oder als Dokument
* (`cancellationConfirmationPath`, Upload). Effekt:
* - Vertrag ACTIVE → CANCELLED (nur aus ACTIVE; andere Status werden
* bewusst nicht angetastet).
* - Berechnetes Vertragsende (`endDate`) = Kündigungs(bestätigungs)datum,
* sofern eines vorliegt.
* Idempotent: läuft nur, wenn tatsächlich eine Bestätigung vorhanden ist,
* und schreibt nur bei echten Änderungen.
*/
export async function maybeCancelOnCancellationConfirmation(
contractId: number,
req: unknown,
): Promise<void> {
const c = await prisma.contract.findUnique({
where: { id: contractId },
select: {
status: true,
endDate: true,
contractNumber: true,
customerId: true,
cancellationConfirmationPath: true,
cancellationConfirmationDate: true,
},
});
if (!c) return;
const hasConfirmation = !!c.cancellationConfirmationPath || !!c.cancellationConfirmationDate;
if (!hasConfirmation) return;
const asDay = (d: Date | null | undefined) =>
d ? new Date(d).toISOString().split('T')[0] : null;
const updateData: Record<string, unknown> = {};
const changes: Record<string, { vorher: unknown; nachher: unknown }> = {};
if (c.status === 'ACTIVE') {
updateData.status = 'CANCELLED';
changes.status = { vorher: 'ACTIVE', nachher: 'CANCELLED' };
}
// Vertragsende = Kündigungsdatum (Bestätigungsdatum), falls vorhanden.
if (c.cancellationConfirmationDate && asDay(c.endDate) !== asDay(c.cancellationConfirmationDate)) {
updateData.endDate = c.cancellationConfirmationDate;
changes.endDate = { vorher: asDay(c.endDate), nachher: asDay(c.cancellationConfirmationDate) };
}
if (Object.keys(updateData).length === 0) return;
await prisma.contract.update({ where: { id: contractId }, data: updateData });
await logChange({
req,
action: 'UPDATE',
resourceType: 'Contract',
resourceId: contractId.toString(),
label: `Vertrag ${c.contractNumber} automatisch aktualisiert (Kündigungsbestätigung)`,
details: { ...changes, trigger: 'Kündigungsbestätigung' },
customerId: c.customerId,
});
}
export async function maybeActivateOnDeliveryConfirmation(
contractId: number,
documentType: string,
+53 -5
View File
@@ -8,6 +8,10 @@ import PDFDocument from 'pdfkit';
import prisma from '../lib/prisma.js';
import { ApiError } from '../utils/apiError.js';
import { getOrCreateProfile } from './companyProfile.service.js';
import { buildZugferdXml } from './zugferd.service.js';
import { embedZugferd } from './zugferdPdf.service.js';
const FONT_DIR = path.join(process.cwd(), 'assets', 'fonts');
function euro(n: number, currency = 'EUR'): string {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency }).format(n || 0);
@@ -41,7 +45,7 @@ async function loadData(creditNoteId: number) {
return { cn, company };
}
export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buffer: Buffer; pdfPath: string }> {
export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buffer: Buffer; pdfPath: string; xml: string }> {
const { cn, company } = await loadData(creditNoteId);
const customer = cn.contract.customer;
const addr = cn.contract.billingAddress || cn.contract.address;
@@ -52,6 +56,11 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf
: `${customer.firstName} ${customer.lastName}`.trim();
const doc = new PDFDocument({ size: 'A4', margin: 50 });
// PDF/A verlangt eingebettete Fonts DejaVuSans (im Repo) statt der
// nicht-eingebetteten pdfkit-Standard-Fonts (Helvetica).
doc.registerFont('Body', path.join(FONT_DIR, 'DejaVuSans.ttf'));
doc.registerFont('Body-Bold', path.join(FONT_DIR, 'DejaVuSans-Bold.ttf'));
doc.font('Body');
const chunks: Buffer[] = [];
doc.on('data', (c: Buffer) => chunks.push(c));
const done = new Promise<Buffer>((resolve) => doc.on('end', () => resolve(Buffer.concat(chunks))));
@@ -97,11 +106,11 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf
const rightX = 545;
const labelW = 300;
const line = (label: string, value: string, bold = false) => {
doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(11);
doc.font(bold ? 'Body-Bold' : 'Body').fontSize(11);
const y = doc.y;
doc.text(label, startX, y, { width: labelW });
doc.text(value, startX, y, { width: rightX - startX, align: 'right' });
doc.font('Helvetica');
doc.font('Body');
};
if (cn.vatRelevant) {
@@ -175,7 +184,46 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf
doc.fillColor('#000');
doc.end();
const buffer = await done;
const baseBuffer = await done;
// ---- ZUGFeRD-XML erzeugen + als hybrides PDF/A-3 einbetten ----
const xml = buildZugferdXml({
number: cn.number,
issueDate: new Date(cn.creditDate),
currency: cn.currency,
seller: {
name: company.name,
street: company.street,
houseNumber: company.houseNumber,
postalCode: company.postalCode,
city: company.city,
country: company.country || 'DE',
vatId: company.vatId || undefined,
taxNumber: company.taxNumber || undefined,
},
buyer: {
name: recipientName,
street: addr?.street || '',
houseNumber: addr?.houseNumber || '',
postalCode: addr?.postalCode || '',
city: addr?.city || '',
country: 'DE',
},
lineName:
cn.type === 'SACHWERT'
? `Sachwert / Subvention: ${cn.sachwertDescription || ''}`.trim()
: 'Subvention (Auszahlung per Überweisung)',
vatRelevant: cn.vatRelevant,
vatRatePercent: cn.vatRate,
amountNet: cn.amountNet,
amountVat: cn.amountVat,
amountGross: cn.amountGross,
});
const buffer = await embedZugferd(baseBuffer, xml, {
title: `Gutschrift ${cn.number}`,
date: new Date(cn.creditDate),
});
// ---- Speichern ----
const dir = path.join(process.cwd(), 'uploads', 'credit-notes');
@@ -187,5 +235,5 @@ export async function generateCreditNotePdf(creditNoteId: number): Promise<{ buf
await prisma.creditNote.update({ where: { id: cn.id }, data: { pdfPath } });
return { buffer, pdfPath };
return { buffer, pdfPath, xml };
}
+156
View File
@@ -0,0 +1,156 @@
// ==================== ZUGFeRD / FACTUR-X XML (CII, EN 16931) ====================
// Erzeugt das Cross-Industry-Invoice-XML (UN/CEFACT CII) für eine Gutschrift
// (Dokumenttyp 381). Profil: EN 16931 ("urn:cen.eu:en16931:2017").
//
// WICHTIG: Muss vor produktivem Einsatz gegen einen ZUGFeRD-/Factur-X-
// Validator geprüft werden. Verkäufer = ausstellende Firma (CompanyProfile),
// Käufer = Kunde. Beträge positiv (der Typcode 381 kennzeichnet die Gutschrift).
export interface ZugferdParty {
name: string;
street: string;
houseNumber: string;
postalCode: string;
city: string;
country: string; // ISO-2
vatId?: string;
taxNumber?: string;
}
export interface ZugferdData {
number: string;
issueDate: Date;
currency: string;
seller: ZugferdParty;
buyer: Omit<ZugferdParty, 'vatId' | 'taxNumber'> & { vatId?: string };
lineName: string;
vatRelevant: boolean;
vatRatePercent: number;
amountNet: number;
amountVat: number;
amountGross: number;
}
function esc(s: string | undefined | null): string {
return (s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const n2 = (n: number) => (Math.round((n + Number.EPSILON) * 100) / 100).toFixed(2);
const date102 = (d: Date) =>
`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
function addressBlock(p: { street: string; houseNumber: string; postalCode: string; city: string; country: string }): string {
const lineOne = `${p.street} ${p.houseNumber}`.trim();
return ` <ram:PostalTradeAddress>
<ram:PostcodeCode>${esc(p.postalCode)}</ram:PostcodeCode>
<ram:LineOne>${esc(lineOne)}</ram:LineOne>
<ram:CityName>${esc(p.city)}</ram:CityName>
<ram:CountryID>${esc(p.country || 'DE')}</ram:CountryID>
</ram:PostalTradeAddress>`;
}
export function buildZugferdXml(d: ZugferdData): string {
// Steuerkategorie: S = Regelsatz, E = steuerbefreit (ohne USt-Ausweis).
const categoryCode = d.vatRelevant ? 'S' : 'E';
const ratePct = d.vatRelevant ? d.vatRatePercent : 0;
const exemptionReason = d.vatRelevant ? '' : 'Kein gesonderter Umsatzsteuerausweis';
const sellerTax: string[] = [];
if (d.seller.vatId) {
sellerTax.push(` <ram:SpecifiedTaxRegistration>
<ram:ID schemeID="VA">${esc(d.seller.vatId)}</ram:ID>
</ram:SpecifiedTaxRegistration>`);
}
if (d.seller.taxNumber) {
sellerTax.push(` <ram:SpecifiedTaxRegistration>
<ram:ID schemeID="FC">${esc(d.seller.taxNumber)}</ram:ID>
</ram:SpecifiedTaxRegistration>`);
}
const buyerTax = d.buyer.vatId
? ` <ram:SpecifiedTaxRegistration>
<ram:ID schemeID="VA">${esc(d.buyer.vatId)}</ram:ID>
</ram:SpecifiedTaxRegistration>`
: '';
const lineTaxCategory = ` <ram:ApplicableTradeTax>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:CategoryCode>${categoryCode}</ram:CategoryCode>
<ram:RateApplicablePercent>${n2(ratePct)}</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>`;
const headerTax = ` <ram:ApplicableTradeTax>
<ram:CalculatedAmount>${n2(d.amountVat)}</ram:CalculatedAmount>
<ram:TypeCode>VAT</ram:TypeCode>${exemptionReason ? `\n <ram:ExemptionReason>${esc(exemptionReason)}</ram:ExemptionReason>` : ''}
<ram:BasisAmount>${n2(d.amountNet)}</ram:BasisAmount>
<ram:CategoryCode>${categoryCode}</ram:CategoryCode>
<ram:RateApplicablePercent>${n2(ratePct)}</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>`;
return `<?xml version="1.0" encoding="UTF-8"?>
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100" xmlns:qdt="urn:un:unece:uncefact:data:standard:QualifiedDataType:100">
<rsm:ExchangedDocumentContext>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:cen.eu:en16931:2017</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>
</rsm:ExchangedDocumentContext>
<rsm:ExchangedDocument>
<ram:ID>${esc(d.number)}</ram:ID>
<ram:TypeCode>381</ram:TypeCode>
<ram:IssueDateTime>
<udt:DateTimeString format="102">${date102(d.issueDate)}</udt:DateTimeString>
</ram:IssueDateTime>
</rsm:ExchangedDocument>
<rsm:SupplyChainTradeTransaction>
<ram:IncludedSupplyChainTradeLineItem>
<ram:AssociatedDocumentLineDocument>
<ram:LineID>1</ram:LineID>
</ram:AssociatedDocumentLineDocument>
<ram:SpecifiedTradeProduct>
<ram:Name>${esc(d.lineName)}</ram:Name>
</ram:SpecifiedTradeProduct>
<ram:SpecifiedLineTradeAgreement>
<ram:NetPriceProductTradePrice>
<ram:ChargeAmount>${n2(d.amountNet)}</ram:ChargeAmount>
</ram:NetPriceProductTradePrice>
</ram:SpecifiedLineTradeAgreement>
<ram:SpecifiedLineTradeDelivery>
<ram:BilledQuantity unitCode="C62">1</ram:BilledQuantity>
</ram:SpecifiedLineTradeDelivery>
<ram:SpecifiedLineTradeSettlement>
${lineTaxCategory}
<ram:SpecifiedTradeSettlementLineMonetarySummation>
<ram:LineTotalAmount>${n2(d.amountNet)}</ram:LineTotalAmount>
</ram:SpecifiedTradeSettlementLineMonetarySummation>
</ram:SpecifiedLineTradeSettlement>
</ram:IncludedSupplyChainTradeLineItem>
<ram:ApplicableHeaderTradeAgreement>
<ram:SellerTradeParty>
<ram:Name>${esc(d.seller.name)}</ram:Name>
${addressBlock(d.seller)}
${sellerTax.join('\n')}
</ram:SellerTradeParty>
<ram:BuyerTradeParty>
<ram:Name>${esc(d.buyer.name)}</ram:Name>
${addressBlock(d.buyer)}${buyerTax ? `\n${buyerTax}` : ''}
</ram:BuyerTradeParty>
</ram:ApplicableHeaderTradeAgreement>
<ram:ApplicableHeaderTradeDelivery/>
<ram:ApplicableHeaderTradeSettlement>
<ram:InvoiceCurrencyCode>${esc(d.currency)}</ram:InvoiceCurrencyCode>
${headerTax}
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
<ram:LineTotalAmount>${n2(d.amountNet)}</ram:LineTotalAmount>
<ram:TaxBasisTotalAmount>${n2(d.amountNet)}</ram:TaxBasisTotalAmount>
<ram:TaxTotalAmount currencyID="${esc(d.currency)}">${n2(d.amountVat)}</ram:TaxTotalAmount>
<ram:GrandTotalAmount>${n2(d.amountGross)}</ram:GrandTotalAmount>
<ram:DuePayableAmount>${n2(d.amountGross)}</ram:DuePayableAmount>
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
</ram:ApplicableHeaderTradeSettlement>
</rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>`;
}
+116
View File
@@ -0,0 +1,116 @@
// ==================== ZUGFeRD PDF/A-3 EMBEDDING ====================
// Nimmt ein bestehendes PDF (pdfkit, mit eingebetteten Fonts) und macht daraus
// ein hybrides ZUGFeRD-PDF: factur-x.xml als AF /Data einbetten, sRGB-
// OutputIntent, XMP-Metadaten (PDF/A-3B + Factur-X-Extension-Schema).
//
// WICHTIG: Vor produktivem Einsatz gegen einen ZUGFeRD-/Factur-X-Validator
// prüfen. Feinheiten der PDF/A-3-Konformität (z.B. Trailer-ID, XMP-Details)
// können nach dem ersten Validator-Lauf noch nachgezogen werden müssen.
import fs from 'fs';
import path from 'path';
import { PDFDocument, AFRelationship, PDFName, PDFString } from 'pdf-lib';
const ICC_PATH = path.join(process.cwd(), 'assets', 'icc', 'sRGB_IEC61966_2_1.icc');
function xmpDate(d: Date): string {
return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
function buildXmp(title: string, date: Date): string {
const d = xmpDate(date);
return `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
<pdfaid:part>3</pdfaid:part>
<pdfaid:conformance>B</pdfaid:conformance>
</rdf:Description>
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title><rdf:Alt><rdf:li xml:lang="x-default">${title.replace(/[<>&]/g, '')}</rdf:li></rdf:Alt></dc:title>
</rdf:Description>
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/">
<xmp:CreatorTool>OpenCRM</xmp:CreatorTool>
<xmp:CreateDate>${d}</xmp:CreateDate>
<xmp:ModifyDate>${d}</xmp:ModifyDate>
</rdf:Description>
<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
<pdfaExtension:schemas>
<rdf:Bag>
<rdf:li rdf:parseType="Resource">
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
<pdfaSchema:namespaceURI>urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#</pdfaSchema:namespaceURI>
<pdfaSchema:prefix>fx</pdfaSchema:prefix>
<pdfaSchema:property>
<rdf:Seq>
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>DocumentFileName</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>Name des eingebetteten XML</pdfaProperty:description></rdf:li>
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>DocumentType</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>INVOICE</pdfaProperty:description></rdf:li>
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>Version</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>Version des Factur-X-Profils</pdfaProperty:description></rdf:li>
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>ConformanceLevel</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>Konformitaetslevel</pdfaProperty:description></rdf:li>
</rdf:Seq>
</pdfaSchema:property>
</rdf:li>
</rdf:Bag>
</pdfaExtension:schemas>
</rdf:Description>
<rdf:Description rdf:about="" xmlns:fx="urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#">
<fx:DocumentType>INVOICE</fx:DocumentType>
<fx:DocumentFileName>factur-x.xml</fx:DocumentFileName>
<fx:Version>1.0</fx:Version>
<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>`;
}
export async function embedZugferd(
basePdf: Buffer,
xml: string,
meta: { title: string; date: Date },
): Promise<Buffer> {
const pdfDoc = await PDFDocument.load(basePdf);
// 1) XML als Associated File (AF /Data) einbetten.
await pdfDoc.attach(Buffer.from(xml, 'utf-8'), 'factur-x.xml', {
mimeType: 'text/xml',
description: 'Factur-X/ZUGFeRD Rechnungsdaten',
afRelationship: AFRelationship.Data,
creationDate: meta.date,
modificationDate: meta.date,
});
// 2) Dokument-Info.
pdfDoc.setTitle(meta.title);
pdfDoc.setProducer('OpenCRM');
pdfDoc.setCreator('OpenCRM');
pdfDoc.setCreationDate(meta.date);
pdfDoc.setModificationDate(meta.date);
// 3) OutputIntent (sRGB) Pflicht für PDF/A.
const iccBytes = fs.readFileSync(ICC_PATH);
const iccStream = pdfDoc.context.stream(iccBytes, { N: 3 });
const iccRef = pdfDoc.context.register(iccStream);
const outputIntent = pdfDoc.context.obj({
Type: 'OutputIntent',
S: 'GTS_PDFA1',
OutputConditionIdentifier: PDFString.of('sRGB'),
Info: PDFString.of('sRGB IEC61966-2.1'),
DestOutputProfile: iccRef,
});
const oiRef = pdfDoc.context.register(outputIntent);
pdfDoc.catalog.set(PDFName.of('OutputIntents'), pdfDoc.context.obj([oiRef]));
// 4) XMP-Metadaten (unkomprimiert, /Metadata /XML).
const xmp = buildXmp(meta.title, meta.date);
const metadataStream = pdfDoc.context.stream(Buffer.from(xmp, 'utf-8'), {
Type: 'Metadata',
Subtype: 'XML',
});
const metaRef = pdfDoc.context.register(metadataStream);
pdfDoc.catalog.set(PDFName.of('Metadata'), metaRef);
// PDF/A: klassische XRef-Tabelle statt Object-Streams (validator-freundlicher).
const out = await pdfDoc.save({ useObjectStreams: false });
return Buffer.from(out);
}
+33 -4
View File
@@ -97,6 +97,22 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🚫 Auto-Kündigung + Cockpit-Filter Kündigungsbestätigung** (2026-08-07)
- **1) Auto-Status bei Kündigungsbestätigung:** Wird zu einem Vertrag eine
Kündigungsbestätigung hinzugefügt als **Datum** (`cancellationConfirmationDate`
übers Formular) und/oder als **Dokument** (Upload) wird der Vertrag von
ACTIVE → **CANCELLED** gesetzt und das **Vertragsende = Kündigungsdatum**.
Zentrale Funktion `maybeCancelOnCancellationConfirmation` (idempotent, nur
aus ACTIVE, nur echte Änderungen); genutzt vom Upload (ersetzt die alte
Inline-Logik, jetzt auch endDate) und vom Update-Controller (nur bei
neu/geändertem Bestätigungsdatum, damit manuelle Status-Korrekturen nicht
zurückgesetzt werden). Verifiziert.
- **2) Cockpit-Filter „Kündigungsbestätigung":** listet alle Verträge mit
hinterlegter Kündigungsbestätigung (Dokument und/oder Datum) in Status
ACTIVE/DRAFT/CANCELLED. Eigene Liste `cancellationConfirmations` im
Cockpit-Result (weil bereits CANCELLED-Verträge mangels „Issue" sonst nicht
auftauchen) + neue Filter-Option im Cockpit-Dropdown. Verifiziert.
- [~] **🧾 Gutschriftsverwaltung (Subventionen am Vertrag) Phase 1: Backend** (2026-08-06)
- Use-Case: zu einem Vertrag kann eine Subvention gewährt werden **Geld**
(Überweisung, EUR) oder **Sachwert** (Smartphone/Elektro …, Textfeld).
@@ -144,10 +160,23 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
Kunden des Vertrags gehört. PDF zeigt bei Überweisung „Unsere
Bankverbindung" + „an Bankkonto: <Kunden-IBAN>". Section-Zeile zeigt das
Auszahlungskonto.
- **Offen:** Phase 3b Teil 2 (ZUGFeRD-XML EN 16931, Typ 381, in PDF/A-3
einbetten **muss gegen ZUGFeRD-Validator** geprüft werden). USt-
Einordnung (Vermittlung vs. Abschlussbonus) mit Steuerberater klären
Modell deckt beide über `vatRelevant` ab.
- **Phase 3b Teil 2 (erledigt):** ZUGFeRD/Factur-X. `zugferd.service.ts`
erzeugt CII-XML (EN 16931 `urn:cen.eu:en16931:2017`, Typ **381**;
Kategorie S bei USt, sonst E + Befreiungsgrund). `zugferdPdf.service.ts`
bettet als hybrides **PDF/A-3B** ein: `factur-x.xml` (AF /Data), sRGB-
OutputIntent (pdfkit-ICC ins Repo kopiert), XMP (pdfaid part=3/conf=B +
Factur-X-Extension-Schema). PDF nutzt **eingebettete DejaVuSans-Fonts**
(im Repo unter `backend/assets/fonts`, Pflicht für PDF/A). Dockerfile
kopiert `backend/assets` ins Runtime-Image. Lokal strukturell verifiziert
(1 Seite, /AF, /Metadata, /OutputIntents, EmbeddedFiles, Font eingebettet,
XML wohlgeformt, TypeCode 381, GrandTotal korrekt).
- **⚠️ VOR PROD:** hybrides PDF gegen einen **ZUGFeRD-/Factur-X-Validator**
prüfen (am besten auf Staging mit echten Firmendaten). Feinheiten
(Trailer-ID, XMP-Details, MIME `text/xml` vs `application/xml`) ggf.
nach dem ersten Validator-Lauf nachziehen.
- **Offen (fachlich):** USt-Einordnung (Vermittlung vs. Abschlussbonus) mit
Steuerberater klären Modell/XML decken beide über `vatRelevant` ab.
ZUGFeRD-Semantik (Seller=Firma, Buyer=Kunde, Typ 381) ggf. anpassen.
- [x] **📄➕ Vertrag kopieren (neuer eigenständiger Vertrag aus Vorlage)** (2026-08-03)
- „Kopieren"-Button in der Vertragsansicht (`contracts:create`) → öffnet das
@@ -478,11 +478,11 @@ export default function CreditNotesSection({ contractId, canEdit }: { contractId
{canEdit && (
<div className="flex items-center gap-1">
{cn.pdfPath ? (
<a href={fileUrl(cn.pdfPath, { inline: true })} target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-blue-600 p-1" title="PDF ansehen">
<a href={fileUrl(cn.pdfPath, { inline: true })} target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-blue-600 p-1" title="PDF (ZUGFeRD) ansehen">
<FileText className="w-4 h-4" />
</a>
) : (
<button onClick={() => pdfMutation.mutate(cn.id)} disabled={pdfMutation.isPending} className="text-gray-400 hover:text-blue-600 p-1" title="PDF erzeugen">
<button onClick={() => pdfMutation.mutate(cn.id)} disabled={pdfMutation.isPending} className="text-gray-400 hover:text-blue-600 p-1" title="PDF (ZUGFeRD) erzeugen">
<FileText className="w-4 h-4" />
</button>
)}
@@ -112,7 +112,7 @@ const categoryLabels: Record<string, string> = {
missingConsents: 'Fehlende Einwilligungen',
};
type FilterType = 'all' | 'critical' | 'warning' | 'ok' | 'deadlines' | 'credentials' | 'data' | 'tasks' | 'review' | 'invoices' | 'consents';
type FilterType = 'all' | 'critical' | 'warning' | 'ok' | 'deadlines' | 'credentials' | 'data' | 'tasks' | 'review' | 'invoices' | 'consents' | 'cancellation_confirmations';
export default function ContractCockpit() {
const [searchParams, setSearchParams] = useSearchParams();
@@ -238,10 +238,14 @@ export default function ContractCockpit() {
return contracts.filter(c =>
c.issues.some(i => ['missing_consents', 'consent_withdrawn'].includes(i.type))
);
case 'cancellation_confirmations':
// Eigene Liste vom Backend (Verträge mit Kündigungsbestätigung in
// Status ACTIVE/DRAFT/CANCELLED), nicht die issue-basierte Liste.
return cockpitData?.data?.cancellationConfirmations ?? [];
default:
return contracts;
}
}, [cockpitData?.data?.contracts, filter]);
}, [cockpitData?.data?.contracts, cockpitData?.data?.cancellationConfirmations, filter]);
if (isLoading) {
return (
@@ -742,8 +746,9 @@ export default function ContractCockpit() {
{ value: 'review', label: `Erneute Prüfung (${summary.byCategory.reviewDue || 0})` },
{ value: 'invoices', label: `Fehlende Rechnungen (${summary.byCategory.missingInvoices || 0})` },
{ value: 'consents', label: `Einwilligungen (${summary.byCategory.missingConsents || 0})` },
{ value: 'cancellation_confirmations', label: `Kündigungsbestätigung (${cockpitData.data.cancellationConfirmations?.length || 0})` },
]}
className="w-64"
className="w-72"
/>
<span className="text-sm text-gray-500">
{filteredContracts.length} Verträge angezeigt
+1
View File
@@ -842,6 +842,7 @@ export interface ReportedMeterReading {
export interface CockpitResult {
contracts: CockpitContract[];
cancellationConfirmations: CockpitContract[];
documentAlerts: DocumentAlert[];
reportedReadings: ReportedMeterReading[];
summary: CockpitSummary;