ADD: added e-rechnung

This commit is contained in:
hwinkel
2026-03-15 20:21:48 +01:00
parent 40a2764dd0
commit 5ac9e269e3
10 changed files with 389 additions and 44 deletions
+2 -10
View File
@@ -276,10 +276,9 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultValues, def
</div>
<div className="border border-gray-200 rounded-xl">
<div className={`grid gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200 text-xs font-medium text-gray-600 rounded-t-xl ${kleinunternehmer ? "grid-cols-11" : "grid-cols-12"}`}>
<div className={`grid gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200 text-xs font-medium text-gray-600 rounded-t-xl ${kleinunternehmer ? "grid-cols-10" : "grid-cols-11"}`}>
<div className="col-span-4">Beschreibung</div>
<div className="col-span-1">Menge</div>
<div className="col-span-1">Einh.</div>
<div className="col-span-2">{kleinunternehmer ? "Einzelpreis (brutto)" : "Einzelpreis"}</div>
{!kleinunternehmer && <div className="col-span-1">MwSt.</div>}
<div className="col-span-2 text-right">Gesamt (brutto)</div>
@@ -287,7 +286,7 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultValues, def
</div>
{fields.map((field, index) => (
<div key={field.id} className={`grid gap-2 px-3 py-2.5 border-b border-gray-100 last:border-0 items-center ${kleinunternehmer ? "grid-cols-11" : "grid-cols-12"}`}>
<div key={field.id} className={`grid gap-2 px-3 py-2.5 border-b border-gray-100 last:border-0 items-center ${kleinunternehmer ? "grid-cols-10" : "grid-cols-11"}`}>
<div className="col-span-4 relative">
{(() => {
const descValue = watchedItems[index]?.description ?? "";
@@ -341,13 +340,6 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultValues, def
onBlur={() => recalcItem(index)}
/>
</div>
<div className="col-span-1">
<Input
{...register(`items.${index}.unit`)}
placeholder="Stück"
className="text-sm"
/>
</div>
<div className="col-span-2">
<Input
{...register(`items.${index}.unitPrice`)}
+1 -4
View File
@@ -112,8 +112,7 @@ const styles = StyleSheet.create({
col_pos: { width: "5%" },
col_desc: { width: "40%" },
col_qty: { width: "10%", textAlign: "right" },
col_unit: { width: "8%", textAlign: "center" },
col_price: { width: "14%", textAlign: "right" },
col_price: { width: "22%", textAlign: "right" },
col_tax: { width: "8%", textAlign: "center" },
col_total: { width: "15%", textAlign: "right" },
totalsSection: {
@@ -324,7 +323,6 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
<Text style={{ ...styles.tableHeaderText, ...styles.col_pos }}>#</Text>
<Text style={{ ...styles.tableHeaderText, ...styles.col_desc }}>Beschreibung</Text>
<Text style={{ ...styles.tableHeaderText, ...styles.col_qty }}>Menge</Text>
<Text style={{ ...styles.tableHeaderText, ...styles.col_unit }}>Einh.</Text>
<Text style={{ ...styles.tableHeaderText, ...styles.col_price }}>
{invoice.kleinunternehmer ? "EP (brutto)" : "EP (netto)"}
</Text>
@@ -339,7 +337,6 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
<Text style={{ ...styles.col_pos, fontSize: 9 }}>{item.position}</Text>
<Text style={{ ...styles.col_desc, fontSize: 9 }}>{item.description}</Text>
<Text style={{ ...styles.col_qty, fontSize: 9 }}>{n(item.quantity)}</Text>
<Text style={{ ...styles.col_unit, fontSize: 9 }}>{item.unit ?? ""}</Text>
<Text style={{ ...styles.col_price, fontSize: 9 }}>{formatMoney(n(item.unitPrice))}</Text>
{!invoice.kleinunternehmer && (
<Text style={{ ...styles.col_tax, fontSize: 9 }}>{n(item.taxRate)}%</Text>
+1
View File
@@ -41,5 +41,6 @@ export default [
route("api/invoices", "routes/api.invoices.ts"),
route("api/invoices/:id", "routes/api.invoices.$id.ts"),
route("api/invoices/:id/pdf", "routes/api.invoices.$id.pdf.ts"),
route("api/invoices/:id/xml", "routes/api.invoices.$id.xml.ts"),
route("api/reports", "routes/api.reports.ts"),
] satisfies RouteConfig;
+156
View File
@@ -0,0 +1,156 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma.server";
const UNIT_C62 = "C62" as const;
const UNIT_HUR = "HUR" as const;
const UNIT_DAY = "DAY" as const;
const UNIT_MON = "MON" as const;
const UNIT_ANN = "ANN" as const;
const UNIT_KMT = "KMT" as const;
type UnitCode = typeof UNIT_C62 | typeof UNIT_HUR | typeof UNIT_DAY | typeof UNIT_MON | typeof UNIT_ANN | typeof UNIT_KMT;
function toUnitCode(unit: string | null | undefined): UnitCode {
if (!unit) return UNIT_C62;
const u = unit.toLowerCase().trim();
if (u === "stunde" || u === "stunden" || u === "h" || u === "std" || u === "hour") return UNIT_HUR;
if (u === "tag" || u === "tage" || u === "day" || u === "days") return UNIT_DAY;
if (u === "monat" || u === "monate" || u === "month") return UNIT_MON;
if (u === "jahr" || u === "jahre" || u === "year") return UNIT_ANN;
if (u === "km") return UNIT_KMT;
return UNIT_C62;
}
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const invoice = await prisma.invoice.findFirst({
where: { id: params.id, company: { userId: user.id } },
include: {
items: { orderBy: { position: "asc" } },
customer: true,
company: true,
},
});
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
const { zugferd } = await import("node-zugferd");
const { EN16931 } = await import("node-zugferd/profile");
const z = zugferd({ profile: EN16931, strict: false });
const isKleinunternehmer = invoice.kleinunternehmer;
const netTotal = Number(invoice.netTotal);
const taxTotal = Number(invoice.taxTotal);
const grossTotal = Number(invoice.grossTotal);
// Group taxes by rate for vatBreakdown
const taxGroups: Record<number, { basis: number; tax: number }> = {};
for (const item of invoice.items) {
const rate = Number(item.taxRate);
if (!taxGroups[rate]) taxGroups[rate] = { basis: 0, tax: 0 };
taxGroups[rate].basis += Number(item.netAmount);
taxGroups[rate].tax += Number(item.taxAmount);
}
const vatBreakdown = isKleinunternehmer
? [
{
calculatedAmount: 0,
typeCode: "VAT",
basisAmount: netTotal,
categoryCode: "E" as const,
rateApplicablePercent: 0,
exemptionReasonText: "Steuerbefreiung gemäß §19 Abs. 1 UStG",
},
]
: Object.entries(taxGroups).map(([rate, { basis, tax }]) => ({
calculatedAmount: tax,
typeCode: "VAT",
basisAmount: basis,
categoryCode: "S" as const,
rateApplicablePercent: Number(rate),
}));
const lines = invoice.items.map((item) => ({
tradeProduct: { name: item.description },
tradeDelivery: {
billedQuantity: {
amount: Number(item.quantity),
unitMeasureCode: toUnitCode(item.unit),
},
},
tradeAgreement: {
netTradePrice: { chargeAmount: Number(item.unitPrice) },
},
tradeSettlement: {
tradeTax: {
typeCode: "VAT",
categoryCode: (isKleinunternehmer ? "E" : "S") as "E" | "S",
rateApplicablePercent: isKleinunternehmer ? 0 : Number(item.taxRate),
},
monetarySummation: { lineTotalAmount: Number(item.netAmount) },
},
}));
const doc = z.create({
number: invoice.number ?? invoice.id,
typeCode: "380",
issueDate: invoice.issueDate,
transaction: {
tradeAgreement: {
seller: {
name: invoice.company.name,
postalAddress: {
...(invoice.company.zip ? { postCode: invoice.company.zip } : {}),
...(invoice.company.address ? { line1: invoice.company.address } : {}),
...(invoice.company.city ? { city: invoice.company.city } : {}),
countryCode: "DE",
},
taxRegistration: {
...(invoice.company.vatId ? { vatIdentifier: invoice.company.vatId } : {}),
...(invoice.company.taxId ? { localIdentifier: invoice.company.taxId } : {}),
},
},
buyer: {
name: invoice.customer.name,
postalAddress: {
...(invoice.customer.zip ? { postCode: invoice.customer.zip } : {}),
...(invoice.customer.address ? { line1: invoice.customer.address } : {}),
...(invoice.customer.city ? { city: invoice.customer.city } : {}),
countryCode: "DE",
},
},
},
tradeDelivery: {
...(invoice.deliveryDate
? { information: { deliveryDate: invoice.deliveryDate } }
: {}),
},
tradeSettlement: {
currencyCode: "EUR",
paymentTerms: { dueDate: invoice.dueDate },
vatBreakdown,
monetarySummation: {
lineTotalAmount: netTotal,
taxBasisTotalAmount: netTotal,
taxTotal: { amount: taxTotal, currencyCode: "EUR" as const },
grandTotalAmount: grossTotal,
duePayableAmount: grossTotal,
},
},
line: lines,
},
});
const xml = await doc.toXML();
return new Response(xml as string, {
status: 200,
headers: {
"Content-Type": "application/xml; charset=utf-8",
"Content-Disposition": `attachment; filename="rechnung-${invoice.number ?? invoice.id}.xml"`,
},
});
}
@@ -168,16 +168,24 @@ export default function InvoiceDetailPage() {
* It then creates a blob URL from the response and creates a new anchor element with the blob URL and a download attribute with the filename.
* It then simulates a click event on the anchor element, so the user is prompted to download the PDF file.
*/
async function downloadPdf() {
const res = await fetch(`/api/invoices/${invoice.id}/pdf`);
async function downloadFile(url: string, filename: string) {
const res = await fetch(url);
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `rechnung-${invoice.number ?? invoice.id}.pdf`;
a.href = objectUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
URL.revokeObjectURL(objectUrl);
}
function downloadPdf() {
return downloadFile(`/api/invoices/${invoice.id}/pdf`, `rechnung-${invoice.number ?? invoice.id}.pdf`);
}
function downloadXml() {
return downloadFile(`/api/invoices/${invoice.id}/xml`, `rechnung-${invoice.number ?? invoice.id}.xml`);
}
return (
@@ -203,9 +211,14 @@ export default function InvoiceDetailPage() {
{/* Invoice Actions */}
<div className="flex items-center gap-2">
{invoice.status !== "DELETED" && (
<Button variant="outline" size="sm" onClick={downloadPdf}>
<Download className="h-4 w-4" /> PDF
</Button>
<>
<Button variant="outline" size="sm" onClick={downloadPdf}>
<Download className="h-4 w-4" /> PDF
</Button>
<Button variant="outline" size="sm" onClick={downloadXml}>
<Download className="h-4 w-4" /> E-Rechnung
</Button>
</>
)}
{invoice.status === "DRAFT" && (
<Button variant="outline" size="sm" asChild>
@@ -336,9 +349,7 @@ export default function InvoiceDetailPage() {
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-gray-500">{item.position}</td>
<td className="px-4 py-3 text-gray-900">{item.description}</td>
<td className="px-4 py-3 text-right text-gray-700">
{item.quantity} {item.unit && <span className="text-gray-500">{item.unit}</span>}
</td>
<td className="px-4 py-3 text-right text-gray-700">{item.quantity}</td>
<td className="px-4 py-3 text-right text-gray-700">{formatCurrency(item.unitPrice)}</td>
<td className="px-4 py-3 text-right text-gray-700">{item.taxRate}%</td>
<td className="px-4 py-3 text-right font-medium text-gray-900">{formatCurrency(item.grossAmount)}</td>
+7 -17
View File
@@ -26,7 +26,6 @@ import { TAX_RATES, formatCurrency } from "@/lib/tax";
const schema = z.object({
name: z.string().min(1, "Pflichtfeld"),
description: z.string().optional(),
unit: z.string().optional(),
unitPrice: z.coerce.number({ invalid_type_error: "Ungültiger Betrag" }),
taxRate: z.coerce.number(),
});
@@ -36,7 +35,6 @@ interface Service {
id: string;
name: string;
description: string | null;
unit: string | null;
unitPrice: number;
taxRate: number;
}
@@ -89,16 +87,10 @@ function ServiceForm({
<Label>Beschreibung</Label>
<Input {...register("description")} placeholder="Optionale Leistungsbeschreibung" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Einheit</Label>
<Input {...register("unit")} placeholder="Stunde, Stück, ..." />
</div>
<div className="space-y-1.5">
<Label>Einzelpreis () *</Label>
<Input {...register("unitPrice")} type="number" step="0.01" placeholder="0.00" />
{errors.unitPrice && <p className="text-xs text-red-600">{errors.unitPrice.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Einzelpreis () *</Label>
<Input {...register("unitPrice")} type="number" step="0.01" placeholder="0.00" />
{errors.unitPrice && <p className="text-xs text-red-600">{errors.unitPrice.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Steuersatz</Label>
@@ -127,7 +119,7 @@ function ServiceForm({
);
}
type SortKey = "name" | "description" | "unit" | "unitPrice" | "taxRate";
type SortKey = "name" | "description" | "unitPrice" | "taxRate";
type SortDir = "asc" | "desc";
export default function LeistungenPage() {
@@ -220,7 +212,6 @@ export default function LeistungenPage() {
defaultValues={{
name: editService.name,
description: editService.description ?? undefined,
unit: editService.unit ?? undefined,
unitPrice: editService.unitPrice,
taxRate: editService.taxRate,
}}
@@ -244,8 +235,8 @@ export default function LeistungenPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 text-xs font-medium text-slate-500 uppercase tracking-wide">
{(["name", "description", "unit", "unitPrice", "taxRate"] as SortKey[]).map((key, i) => {
const labels: Record<SortKey, string> = { name: "Bezeichnung", description: "Beschreibung", unit: "Einheit", unitPrice: "Preis", taxRate: "MwSt." };
{(["name", "description", "unitPrice", "taxRate"] as SortKey[]).map((key, i) => {
const labels: Record<SortKey, string> = { name: "Bezeichnung", description: "Beschreibung", unitPrice: "Preis", taxRate: "MwSt." };
const isNum = key === "unitPrice" || key === "taxRate";
const active = sortKey === key;
const Icon = active ? (sortDir === "asc" ? ChevronUp : ChevronDown) : ChevronsUpDown;
@@ -270,7 +261,6 @@ export default function LeistungenPage() {
<tr key={service.id} className="hover:bg-slate-50 transition-colors">
<td className="px-4 py-3 font-medium text-slate-800">{service.name}</td>
<td className="px-4 py-3 text-slate-500 max-w-xs truncate">{service.description ?? "-"}</td>
<td className="px-4 py-3 text-slate-500">{service.unit ?? "-"}</td>
<td className="px-4 py-3 text-right text-slate-800">{formatCurrency(service.unitPrice)}</td>
<td className="px-4 py-3 text-right text-slate-500">{service.taxRate}%</td>
<td className="px-4 py-3">