mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
see comments
This commit is contained in:
parent
0d1eb75127
commit
c7c7c47ad7
9 changed files with 65 additions and 18 deletions
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue