From c7c7c47ad7b00524a284eabd5accecd6f8e012e9 Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Tue, 29 Sep 2026 13:46:50 +0200 Subject: [PATCH] see comments --- app/api/invoices/pdf/route.ts | 7 ++++++- modules/shared/actions/invoices.ts | 11 +++++++--- modules/shared/actions/stammdaten.ts | 7 ++++++- modules/shared/lib/invoice-math.ts | 28 ++++++++++++++++++------- modules/shared/pdf/invoice-document.tsx | 4 +++- modules/shared/queries/profile.ts | 5 ++++- modules/shared/schemas/invoice.ts | 13 +++++++++--- package-lock.json | 7 +++++++ package.json | 1 + 9 files changed, 65 insertions(+), 18 deletions(-) diff --git a/app/api/invoices/pdf/route.ts b/app/api/invoices/pdf/route.ts index eaf614c..4f2d919 100644 --- a/app/api/invoices/pdf/route.ts +++ b/app/api/invoices/pdf/route.ts @@ -15,8 +15,13 @@ export async function GET(request: NextRequest) { const parsed = IdsSchema.safeParse(request.nextUrl.searchParams.getAll("id")) if (!parsed.success) return Response.json({ error: "Ungültige Auswahl." }, { status: 400 }) - const invoices = await getInvoicesForPdf(parsed.data) + const requestedIds = [...new Set(parsed.data)] + const invoices = await getInvoicesForPdf(requestedIds) if (invoices.length === 0) return Response.json({ error: "Keine Rechnungen gefunden." }, { status: 404 }) + // RLS filtert unerlaubte/stornierte Rechnungen stillschweigend raus – dann lieber gar nichts liefern als ein unvollständiges ZIP. + if (invoices.length !== requestedIds.length) { + return Response.json({ error: "Nicht alle ausgewählten Rechnungen sind verfügbar." }, { status: 404 }) + } if (invoices.length === 1) { const pdf = await renderInvoicePdf(invoices[0]) diff --git a/modules/shared/actions/invoices.ts b/modules/shared/actions/invoices.ts index 216b97e..fcadb23 100644 --- a/modules/shared/actions/invoices.ts +++ b/modules/shared/actions/invoices.ts @@ -74,16 +74,21 @@ export async function updateInvoiceStatus( const validated = StatusSchema.safeParse({ profileId, invoiceId, status }); if (!validated.success) return { error: "Ungültiger Status." }; + const clientId = await getClientId(validated.data.profileId); + if (!clientId) return { error: "Status konnte nicht geändert werden." }; + const supabase = await createClient(); - const { error } = await supabase + const { data, error } = await supabase .from("invoices") .update({ status: validated.data.status, paid_at: validated.data.status === "paid" ? toIsoDate(new Date()) : null, }) - .eq("id", validated.data.invoiceId); + .eq("id", validated.data.invoiceId) + .eq("client_id", clientId) + .select("id"); - if (error) return { error: "Status konnte nicht geändert werden." }; + if (error || !data?.length) return { error: "Status konnte nicht geändert werden." }; revalidateBilling(validated.data.profileId); return { success: true }; diff --git a/modules/shared/actions/stammdaten.ts b/modules/shared/actions/stammdaten.ts index 5eab1c8..10edacf 100644 --- a/modules/shared/actions/stammdaten.ts +++ b/modules/shared/actions/stammdaten.ts @@ -169,7 +169,12 @@ export async function saveStammdaten( contact_phone: data.contactPhone ?? null, }; - let clientId = await getClientId(data.profileId); + let clientId: string | null; + try { + clientId = await getClientId(data.profileId); + } catch { + return { error: "Profil konnte nicht geladen werden." }; + } if (clientId) { const { error } = await supabase.from("clients").update(clientPayload).eq("id", clientId); diff --git a/modules/shared/lib/invoice-math.ts b/modules/shared/lib/invoice-math.ts index d0ab283..4283bce 100644 --- a/modules/shared/lib/invoice-math.ts +++ b/modules/shared/lib/invoice-math.ts @@ -1,19 +1,31 @@ +import Decimal from "decimal.js"; + type Item = { quantity: number; unitPrice: number; taxRate: number }; -const round2 = (value: number) => Math.round(value * 100) / 100; +// Dezimal statt Float, damit Summen exakt der View invoices_overview entsprechen +// (Float: 0.01 * 14.50 = 0.14499… → 0.14, Postgres numeric: 0.15). +const decimal = (value: number) => new Decimal(value.toString()); +const round2 = (value: Decimal) => value.toDecimalPlaces(2, Decimal.ROUND_HALF_UP); + +const lineNetDecimal = (item: Item) => round2(decimal(item.quantity).times(decimal(item.unitPrice))); + +const lineTaxDecimal = (item: Item) => + round2(decimal(item.quantity).times(decimal(item.unitPrice)).times(decimal(item.taxRate)).div(100)); -// Gleiche Rundung pro Position wie die View invoices_overview. export function lineNet(item: Item) { - return round2(item.quantity * item.unitPrice); + return lineNetDecimal(item).toNumber(); } export function invoiceTotals(items: Item[]) { - const net = round2(items.reduce((sum, item) => sum + lineNet(item), 0)); - const tax = round2( - items.reduce((sum, item) => sum + round2((item.quantity * item.unitPrice * item.taxRate) / 100), 0) - ); + const net = items.reduce((sum, item) => sum.plus(lineNetDecimal(item)), new Decimal(0)); + const tax = items.reduce((sum, item) => sum.plus(lineTaxDecimal(item)), new Decimal(0)); const rates = new Set(items.map((item) => item.taxRate)); - return { net, tax, gross: round2(net + tax), uniformTaxRate: rates.size === 1 ? [...rates][0] : null }; + return { + net: net.toNumber(), + tax: tax.toNumber(), + gross: net.plus(tax).toNumber(), + uniformTaxRate: rates.size === 1 ? [...rates][0] : null, + }; } const numberFormat = new Intl.NumberFormat("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); diff --git a/modules/shared/pdf/invoice-document.tsx b/modules/shared/pdf/invoice-document.tsx index 18d075b..43a5959 100644 --- a/modules/shared/pdf/invoice-document.tsx +++ b/modules/shared/pdf/invoice-document.tsx @@ -15,6 +15,8 @@ const COMPANY = { bic: "DEUTDE3BP10", bank: "Postbank", partners: "Ben Luis Dalhoff, Rajpreet Singh", + // Kleinunternehmerregelung nach § 19 UStG. Bei Wechsel zur Regelbesteuerung auf false setzen. + smallBusiness: true, } const INK = "#1a1a1a" @@ -164,7 +166,7 @@ function InvoicePage({ invoice, assets }: { invoice: InvoicePdfData; assets: Inv {formatCurrency(totals.gross)} {formatCurrency(totals.gross)} - {totals.tax === 0 && ( + {COMPANY.smallBusiness && totals.tax === 0 && ( Gemäß § 19 UStG enthält der Rechnungsbetrag keine Umsatzsteuer. )} diff --git a/modules/shared/queries/profile.ts b/modules/shared/queries/profile.ts index 5c75d8d..1671d37 100644 --- a/modules/shared/queries/profile.ts +++ b/modules/shared/queries/profile.ts @@ -1,13 +1,16 @@ import "server-only" import { createClient } from "@/lib/supabase/server" +// null = Profil fehlt oder hat noch keinen Client. DB-Fehler werfen, damit sie nicht als "kein Client" durchgehen. export async function getClientId(profileId: string): Promise { const supabase = await createClient() - const { data } = await supabase + const { data, error } = await supabase .from("profiles") .select("client_id") .eq("id", profileId) .maybeSingle() + if (error) throw new Error("Profil konnte nicht geladen werden.") + return data?.client_id ?? null } diff --git a/modules/shared/schemas/invoice.ts b/modules/shared/schemas/invoice.ts index 8b38678..c3e21f3 100644 --- a/modules/shared/schemas/invoice.ts +++ b/modules/shared/schemas/invoice.ts @@ -5,12 +5,19 @@ export type InvoiceStatus = (typeof INVOICE_STATUSES)[number]; const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { error: "Ungültiges Datum." }); +// Grenzen und Nachkommastellen passend zu invoice_items: quantity numeric(10,2), unit_price numeric(12,2), tax_rate numeric(5,2) +const twoDecimals = (label: string) => z.number().multipleOf(0.01, { error: `${label}: maximal 2 Nachkommastellen.` }); + export const InvoiceItemSchema = z.object({ productTemplateId: z.string().uuid().nullable(), description: z.string().trim().min(1, { error: "Jede Position braucht eine Bezeichnung." }), - quantity: z.number().positive({ error: "Menge muss größer als 0 sein." }), - unitPrice: z.number().min(0, { error: "Preis darf nicht negativ sein." }), - taxRate: z.number().min(0).max(100), + quantity: twoDecimals("Menge") + .positive({ error: "Menge muss größer als 0 sein." }) + .lt(1e8, { error: "Menge ist zu groß." }), + unitPrice: twoDecimals("Preis") + .min(0, { error: "Preis darf nicht negativ sein." }) + .lt(1e10, { error: "Preis ist zu groß." }), + taxRate: twoDecimals("MwSt").min(0).max(100), }); export const InvoiceDraftSchema = z diff --git a/package-lock.json b/package-lock.json index 2c23231..5e046e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@supabase/supabase-js": "^2.117.2", "class-variance-authority": "^0.7.1", "cn": "^0.4.0", + "decimal.js": "^10.6.0", "fflate": "^0.8.3", "lucide-react": "^1.48.0", "next": "16.3.6", @@ -4349,6 +4350,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", diff --git a/package.json b/package.json index 32eb3e2..4083bda 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@supabase/supabase-js": "^2.117.2", "class-variance-authority": "^0.7.1", "cn": "^0.4.0", + "decimal.js": "^10.6.0", "fflate": "^0.8.3", "lucide-react": "^1.48.0", "next": "16.3.6",