see comments

This commit is contained in:
Rajpreet0 2026-09-29 13:46:50 +02:00
parent 0d1eb75127
commit c7c7c47ad7
9 changed files with 65 additions and 18 deletions

View file

@ -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 };

View file

@ -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);

View file

@ -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 });

View file

@ -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
<Cell width={SUMMARY_COLUMNS[4]} align="right">{formatCurrency(totals.gross)}</Cell>
<Cell width={SUMMARY_COLUMNS[5]} align="right" style={styles.total}>{formatCurrency(totals.gross)}</Cell>
</View>
{totals.tax === 0 && (
{COMPANY.smallBusiness && totals.tax === 0 && (
<Text style={styles.note}>Gemäß § 19 UStG enthält der Rechnungsbetrag keine Umsatzsteuer.</Text>
)}
</View>

View file

@ -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<string | null> {
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
}

View file

@ -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