ADD: fixed e rechnung
This commit is contained in:
+13
-2
@@ -1,3 +1,14 @@
|
||||
# Datenbank (für lokale Entwicklung)
|
||||
DATABASE_URL="mysql://user:password@localhost:3306/annas_rechnungsmanager"
|
||||
AUTH_SECRET="your-random-secret-here"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
|
||||
# Session-Secret – zufälligen Wert generieren: openssl rand -base64 32
|
||||
AUTH_SECRET="HIER_ZUFAELLIGEN_WERT_EINSETZEN"
|
||||
|
||||
# Docker-Compose: Datenbank-Credentials
|
||||
DB_ROOT_PASSWORD="sicheres_root_passwort"
|
||||
DB_USER="annas_user"
|
||||
DB_PASSWORD="sicheres_db_passwort"
|
||||
DB_NAME="annas_rechnungen"
|
||||
|
||||
# Docker-Compose: Admin-Passwort (nur beim ersten Start relevant)
|
||||
ADMIN_PASSWORD="sicheres_admin_passwort"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { RateLimiterMemory } from "rate-limiter-flexible";
|
||||
|
||||
// Max. 5 Loginversuche pro IP innerhalb von 15 Minuten
|
||||
const loginLimiter = new RateLimiterMemory({
|
||||
points: 5,
|
||||
duration: 60 * 15,
|
||||
});
|
||||
|
||||
export async function checkLoginRateLimit(request: Request): Promise<string | null> {
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
"unknown";
|
||||
|
||||
try {
|
||||
await loginLimiter.consume(ip);
|
||||
return null;
|
||||
} catch {
|
||||
return "Zu viele Loginversuche. Bitte 15 Minuten warten.";
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@ export function ErrorBoundary() {
|
||||
<body style={{ fontFamily: "monospace", padding: "2rem", background: "#fff1f2", color: "#9f1239" }}>
|
||||
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1rem" }}>Fehler</h1>
|
||||
<pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word", background: "#ffe4e6", padding: "1rem", borderRadius: "0.5rem" }}>{message}</pre>
|
||||
{stack && <pre style={{ marginTop: "1rem", fontSize: "0.75rem", color: "#64748b", whiteSpace: "pre-wrap" }}>{stack}</pre>}
|
||||
{import.meta.env.DEV && stack && <pre style={{ marginTop: "1rem", fontSize: "0.75rem", color: "#64748b", whiteSpace: "pre-wrap" }}>{stack}</pre>}
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma.server";
|
||||
import { log } from "@/lib/logger.server";
|
||||
import { z } from "zod";
|
||||
|
||||
const companySchema = z.object({
|
||||
@@ -39,7 +40,8 @@ export async function action({ request, params }: { request: Request; params: {
|
||||
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
if (request.method === "DELETE") {
|
||||
await prisma.company.delete({ where: { id: params.id } });
|
||||
await prisma.company.delete({ where: { id: params.id, userId: user.id } });
|
||||
await log({ userId: user.id, action: "DELETE_COMPANY", entity: "Company", entityId: params.id, request });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function action({ request, params }: { request: Request; params: {
|
||||
if (!customer) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
if (request.method === "DELETE") {
|
||||
await prisma.customer.delete({ where: { id: params.id } });
|
||||
await prisma.customer.delete({ where: { id: params.id, company: { userId: user.id } } });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getApiUser } from "@/session.server";
|
||||
import prisma from "@/lib/prisma.server";
|
||||
import { generateInvoiceNumber } from "@/lib/invoice-number.server";
|
||||
import { log } from "@/lib/logger.server";
|
||||
import { InvoiceStatus } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -43,9 +44,9 @@ const updateSchema = z.object({
|
||||
notes: z.string().optional(),
|
||||
kleinunternehmer: z.boolean().optional().default(false),
|
||||
items: z.array(itemSchema).min(1),
|
||||
netTotal: z.number(),
|
||||
taxTotal: z.number(),
|
||||
grossTotal: z.number(),
|
||||
netTotal: z.number().nonnegative(),
|
||||
taxTotal: z.number().nonnegative(),
|
||||
grossTotal: z.number().nonnegative(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: { request: Request; params: { id: string } }) {
|
||||
@@ -92,6 +93,7 @@ export async function action({ request, params }: { request: Request; params: {
|
||||
);
|
||||
}
|
||||
await prisma.invoice.delete({ where: { id: params.id } });
|
||||
await log({ userId: user.id, action: "DELETE_INVOICE", entity: "Invoice", entityId: params.id, request });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,17 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
|
||||
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const missingFields: string[] = [];
|
||||
if (!invoice.company.email && !invoice.company.phone) {
|
||||
missingFields.push("Firma: E-Mail oder Telefon (Kontaktdaten, BR-DE-2)");
|
||||
}
|
||||
if (missingFields.length > 0) {
|
||||
return Response.json(
|
||||
{ error: "Pflichtfelder für E-Rechnung fehlen", missingFields },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const { zugferd } = await import("node-zugferd");
|
||||
const { EN16931 } = await import("node-zugferd/profile");
|
||||
|
||||
@@ -73,7 +84,8 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
rateApplicablePercent: Number(rate),
|
||||
}));
|
||||
|
||||
const lines = invoice.items.map((item) => ({
|
||||
const lines = invoice.items.map((item, index) => ({
|
||||
identifier: String(index + 1),
|
||||
tradeProduct: { name: item.description },
|
||||
tradeDelivery: {
|
||||
billedQuantity: {
|
||||
@@ -95,11 +107,14 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
}));
|
||||
|
||||
const doc = z.create({
|
||||
businessProcessType: "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0",
|
||||
specificationIdentifier: "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0",
|
||||
number: invoice.number ?? invoice.id,
|
||||
typeCode: "380",
|
||||
issueDate: invoice.issueDate,
|
||||
transaction: {
|
||||
tradeAgreement: {
|
||||
buyerReference: invoice.number ?? invoice.id,
|
||||
seller: {
|
||||
name: invoice.company.name,
|
||||
postalAddress: {
|
||||
@@ -108,6 +123,18 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
...(invoice.company.city ? { city: invoice.company.city } : {}),
|
||||
countryCode: "DE",
|
||||
},
|
||||
...(invoice.company.email || invoice.company.phone
|
||||
? {
|
||||
tradeContact: {
|
||||
name: invoice.company.name,
|
||||
...(invoice.company.email ? { emailAddress: invoice.company.email } : {}),
|
||||
...(invoice.company.phone ? { phoneNumber: invoice.company.phone } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(invoice.company.email
|
||||
? { electronicAddress: { value: invoice.company.email, schemeIdentifier: "EM" as const } }
|
||||
: {}),
|
||||
taxRegistration: {
|
||||
...(invoice.company.vatId ? { vatIdentifier: invoice.company.vatId } : {}),
|
||||
...(invoice.company.taxId ? { localIdentifier: invoice.company.taxId } : {}),
|
||||
@@ -121,6 +148,9 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
...(invoice.customer.city ? { city: invoice.customer.city } : {}),
|
||||
countryCode: "DE",
|
||||
},
|
||||
...(invoice.customer.email
|
||||
? { electronicAddress: { value: invoice.customer.email, schemeIdentifier: "EM" as const } }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
tradeDelivery: {
|
||||
@@ -131,6 +161,18 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
tradeSettlement: {
|
||||
currencyCode: "EUR",
|
||||
paymentTerms: { dueDate: invoice.dueDate },
|
||||
...(invoice.company.bankIban
|
||||
? {
|
||||
paymentInstruction: {
|
||||
typeCode: "58" as const,
|
||||
transfers: [{ paymentAccountIdentifier: invoice.company.bankIban }],
|
||||
},
|
||||
}
|
||||
: {
|
||||
paymentInstruction: {
|
||||
typeCode: "ZZZ" as const,
|
||||
},
|
||||
}),
|
||||
vatBreakdown,
|
||||
monetarySummation: {
|
||||
lineTotalAmount: netTotal,
|
||||
@@ -144,9 +186,15 @@ export async function loader({ request, params }: { request: Request; params: {
|
||||
},
|
||||
});
|
||||
|
||||
const xml = await doc.toXML();
|
||||
let xml: string;
|
||||
try {
|
||||
xml = await doc.toXML() as string;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unbekannter Fehler";
|
||||
return Response.json({ error: `E-Rechnung konnte nicht erstellt werden: ${message}` }, { status: 422 });
|
||||
}
|
||||
|
||||
return new Response(xml as string, {
|
||||
return new Response(xml, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function action({ request, params }: { request: Request; params: {
|
||||
if (!service) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
if (request.method === "DELETE") {
|
||||
await prisma.service.delete({ where: { id: params.id } });
|
||||
await prisma.service.delete({ where: { id: params.id, company: { userId: user.id } } });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +170,17 @@ export default function InvoiceDetailPage() {
|
||||
*/
|
||||
async function downloadFile(url: string, filename: string) {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = await res.json() as { error?: string; missingFields?: string[] };
|
||||
const detail = data.missingFields?.length
|
||||
? `\n\n• ${data.missingFields.join("\n• ")}`
|
||||
: "";
|
||||
alert(`${data.error ?? "Fehler"}${detail}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
@@ -184,6 +194,11 @@ export default function InvoiceDetailPage() {
|
||||
return downloadFile(`/api/invoices/${invoice.id}/pdf`, `rechnung-${invoice.number ?? invoice.id}.pdf`);
|
||||
}
|
||||
|
||||
const xmlMissingFields: string[] = [];
|
||||
if (!invoice.company.email && !invoice.company.phone) {
|
||||
xmlMissingFields.push("E-Mail oder Telefon der Firma fehlt");
|
||||
}
|
||||
|
||||
function downloadXml() {
|
||||
return downloadFile(`/api/invoices/${invoice.id}/xml`, `rechnung-${invoice.number ?? invoice.id}.xml`);
|
||||
}
|
||||
@@ -215,9 +230,20 @@ export default function InvoiceDetailPage() {
|
||||
<Button variant="outline" size="sm" onClick={downloadPdf}>
|
||||
<Download className="h-4 w-4" /> PDF
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={downloadXml}>
|
||||
<span
|
||||
title={xmlMissingFields.length > 0 ? `Pflichtfelder fehlen:\n• ${xmlMissingFields.join("\n• ")}` : undefined}
|
||||
className="inline-flex"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={downloadXml}
|
||||
disabled={xmlMissingFields.length > 0}
|
||||
className={xmlMissingFields.length > 0 ? "pointer-events-none opacity-50" : ""}
|
||||
>
|
||||
<Download className="h-4 w-4" /> E-Rechnung
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{invoice.status === "DRAFT" && (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Form, useActionData, useNavigation, redirect } from "react-router";
|
||||
import { login, createUserSession, getUserSession } from "@/session.server";
|
||||
import { checkLoginRateLimit } from "@/lib/rate-limiter.server";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -13,6 +14,9 @@ export async function loader({ request }: { request: Request }) {
|
||||
}
|
||||
|
||||
export async function action({ request }: { request: Request }) {
|
||||
const rateLimitError = await checkLoginRateLimit(request);
|
||||
if (rateLimitError) return { error: rateLimitError };
|
||||
|
||||
const formData = await request.formData();
|
||||
const identifier = formData.get("identifier") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
@@ -3,15 +3,19 @@ import bcrypt from "bcryptjs";
|
||||
import prisma from "@/lib/prisma.server";
|
||||
import { log } from "@/lib/logger.server";
|
||||
|
||||
if (!process.env.AUTH_SECRET) {
|
||||
throw new Error("AUTH_SECRET environment variable is required");
|
||||
}
|
||||
|
||||
const sessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: "__session",
|
||||
httpOnly: true,
|
||||
maxAge: process.env.NODE_ENV === "development" ? 60 * 60 * 24 * 30 : 60 * 60 * 4,
|
||||
maxAge: 60 * 60 * 4, // 4 Stunden
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secrets: [process.env.AUTH_SECRET ?? "fallback-secret-change-in-production"],
|
||||
secure: process.env.SESSION_SECURE === "true",
|
||||
secrets: [process.env.AUTH_SECRET],
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -28,6 +32,8 @@ export async function login(
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// Dummy-Vergleich verhindert Timing-Angriffe zur Benutzernamen-Enumeration
|
||||
await bcrypt.compare(password, "$2a$12$dummyhashfortimingattackprevention000000000000000000000");
|
||||
await log({ action: "LOGIN_FAILED", metadata: { identifier }, request });
|
||||
return null;
|
||||
}
|
||||
|
||||
+7
-7
@@ -4,10 +4,10 @@ services:
|
||||
container_name: annas_mariadb
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: rootpassword
|
||||
MYSQL_DATABASE: annas_rechnungen
|
||||
MYSQL_USER: annas_user
|
||||
MYSQL_PASSWORD: annas_password
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${DB_NAME:-annas_rechnungen}
|
||||
MYSQL_USER: ${DB_USER}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- mariadb_data:/var/lib/mysql
|
||||
networks:
|
||||
@@ -27,12 +27,12 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: mysql://annas_user:annas_password@mariadb:3306/annas_rechnungen
|
||||
AUTH_SECRET: changeme123
|
||||
DATABASE_URL: mysql://${DB_USER}:${DB_PASSWORD}@mariadb:3306/${DB_NAME:-annas_rechnungen}
|
||||
AUTH_SECRET: ${AUTH_SECRET}
|
||||
NODE_ENV: production
|
||||
# Beim ersten Start wird der Admin-Benutzer (username: admin) mit diesem Passwort angelegt.
|
||||
# Nach dem ersten Login in der App ändern und hier leer lassen oder entfernen.
|
||||
ADMIN_PASSWORD: changeme123
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
|
||||
Generated
+6
@@ -30,6 +30,7 @@
|
||||
"node-cron": "^4.2.1",
|
||||
"node-zugferd": "^0.1.1-beta.1",
|
||||
"prisma": "^5.22.0",
|
||||
"rate-limiter-flexible": "^10.0.1",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"react-hook-form": "^7.71.2",
|
||||
@@ -5922,6 +5923,11 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/rate-limiter-flexible": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-10.0.1.tgz",
|
||||
"integrity": "sha512-3G6GMFz5Oz5nVnDv9gQ1LLMdExR4B1lOjogPIjehtgyxPMIkY09BGyk2eCYt36/OkV/0t12GEt6J6HpTl6RzZg=="
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"node-cron": "^4.2.1",
|
||||
"node-zugferd": "^0.1.1-beta.1",
|
||||
"prisma": "^5.22.0",
|
||||
"rate-limiter-flexible": "^10.0.1",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"react-hook-form": "^7.71.2",
|
||||
|
||||
Reference in New Issue
Block a user