feat: Umsatzsteuer ueber VAT_RATE_PERCENT konfigurierbar und auf der Rechnung ausgewiesen
Die Rechnung wies bisher gar keine Steuer aus — weder Satz und Betrag noch
den Pflichthinweis nach § 19 UStG. Der Satz kommt jetzt aus
VAT_RATE_PERCENT und steht auf PDF und HTML-Ansicht: 7 (ermaessigt, Honig
als Lebensmittel), 19 (voll) oder 0 fuer die Kleinunternehmerregelung, bei
der anstelle der Steuerzeilen der vorgeschriebene Hinweis erscheint.
DIE SHOP-PREISE SIND BRUTTOPREISE. Gegenueber Verbrauchern ist der
Gesamtpreis inklusive Umsatzsteuer anzugeben (§ 1 PAngV), und genau dieser
Betrag steht in Product.priceCents und Order.totalCents. Die Steuer wird
deshalb herausgerechnet, nicht aufgeschlagen — ein Aufschlag ergaebe einen
anderen Endpreis als den, den die Kundschaft im Warenkorb gesehen und
bezahlt hat.
Gerechnet wird auf der Gesamtsumme statt je Position, damit sich Rundungen
einzelner Zeilen nicht aufsummieren, und der Steuerbetrag ergibt sich als
Rest statt aus einer zweiten Rundung: nur so gilt immer netto + steuer ===
brutto. Eine Rechnung, die sich nicht aufaddiert, ist keine brauchbare
Rechnung. Der Versand traegt den Satz der Ware, er ist umsatzsteuerlich
eine Nebenleistung.
Der Satz wird als Invoice.vatRatePercent FESTGESCHRIEBEN statt bei jeder
Anzeige aus der Umgebung gelesen. Wechselt der Steuerstatus des Betriebs,
muessen alte Rechnungen weiterhin den damals ausgewiesenen Satz zeigen — ein
Beleg ist die Aufzeichnung eines vergangenen Vorgangs, keine Ansicht auf die
heutige Konfiguration. Bestehende Rechnungen tragen null und zeigen wie
bisher keine Steuerzeilen, weil rueckwirkend nicht feststellbar ist, welcher
Satz gegolten haette.
Ein unbrauchbarer Wert faellt auf 7% zurueck und wird laut geloggt, statt
eine bereits bezahlte Bestellung scheitern zu lassen. Damit das nicht
unbemerkt bleibt, prueft "pnpm preflight" den Wert jetzt als Blocker und
warnt zusaetzlich bei einem in Deutschland unueblichen Satz.
Der bisherige legalNote war ein Platzhalter ("Kleinunternehmerregelung /
Umsatzsteuerausweis gemaess tatsaechlichem Status ergaenzen") und ist
ersetzt, da die Angabe nun tatsaechlich erfolgt.
Bekannte Grenze, im README vermerkt: es gibt genau EINEN Satz. Honig ist
ermaessigt besteuert, Kerzen nicht — werden beide verkauft, braucht es einen
Satz je ProductCategory.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
70ede77dde
commit
7c59283ede
15 files changed
+334
-7
No files matched your search
@@ -9,6 +9,7 @@ import { getBaseUrl } from "@/lib/url";
|
||||
import { getInvoiceByNumber } from "@/lib/account-data";
|
||||
import { generateInvoicePdf } from "@/lib/invoice-pdf";
|
||||
import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage";
|
||||
import { vatRatePercent } from "@/lib/vat";
|
||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
@@ -40,6 +41,7 @@ async function storeInvoiceDocument(invoiceNumber: string): Promise<Buffer | nul
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
issuedAt: invoice.issuedAt,
|
||||
totalCents: invoice.totalCents,
|
||||
vatRatePercent: invoice.vatRatePercent,
|
||||
order: invoice.order,
|
||||
});
|
||||
|
||||
@@ -206,6 +208,10 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||
orderId: order.id,
|
||||
invoiceNumber: `RE-${new Date().getFullYear()}-${randomBytes(4).toString("hex").toUpperCase()}`,
|
||||
totalCents: order.totalCents,
|
||||
// Satz festschreiben, nicht bei jeder Anzeige neu aus der Umgebung
|
||||
// lesen: aendert sich der Steuerstatus des Betriebs, muss diese
|
||||
// Rechnung weiterhin den damals gueltigen Satz ausweisen.
|
||||
vatRatePercent: vatRatePercent(process.env.VAT_RATE_PERCENT),
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { getInvoiceForCustomer } from "@/lib/account-data";
|
||||
import { formatPrice, formatDate } from "@/lib/format";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { splitGross } from "@/lib/vat";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
@@ -23,6 +24,12 @@ export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNu
|
||||
|
||||
const { order } = invoice;
|
||||
const company = await getCompanyInfo();
|
||||
// Nur einmal aufteilen statt je Zeile: derselbe Betrag zweimal zu zerlegen
|
||||
// koennte bei spaeteren Aenderungen auseinanderlaufen.
|
||||
const vat =
|
||||
invoice.vatRatePercent != null && invoice.vatRatePercent > 0
|
||||
? splitGross(invoice.totalCents, invoice.vatRatePercent)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="py-16 print:py-0">
|
||||
@@ -99,10 +106,30 @@ export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNu
|
||||
<span>−{formatPrice(order.discountCents, order.currency)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Steuerausweis mit demselben an der Rechnung festgeschriebenen
|
||||
Satz wie im PDF — beide Darstellungen desselben Belegs muessen
|
||||
uebereinstimmen. */}
|
||||
{vat ? (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink/70">{t.invoicePage.netLabel}</span>
|
||||
<span>{formatPrice(vat.netCents, order.currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink/70">{t.invoicePage.vatLabel(invoice.vatRatePercent ?? 0)}</span>
|
||||
<span>{formatPrice(vat.vatCents, order.currency)}</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="flex justify-between text-forest pt-2 border-t border-ink/10">
|
||||
<span>{t.invoicePage.totalLabel}</span>
|
||||
<span>{formatPrice(invoice.totalCents, order.currency)}</span>
|
||||
</div>
|
||||
{/* Pflichtangabe bei Kleinunternehmerregelung — steht anstelle der
|
||||
Steuerzeilen, nicht zusaetzlich. */}
|
||||
{invoice.vatRatePercent === 0 ? (
|
||||
<p className="pt-2 text-xs text-ink/65">{t.invoicePage.smallBusinessNote}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export async function GET(
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
issuedAt: invoice.issuedAt,
|
||||
totalCents: invoice.totalCents,
|
||||
vatRatePercent: invoice.vatRatePercent,
|
||||
order: invoice.order,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1227,9 +1227,11 @@ export const de: Dictionary = {
|
||||
shippingLabel: "Versand",
|
||||
freeShipping: "kostenlos",
|
||||
discountLabel: "Rabatt",
|
||||
netLabel: "Nettobetrag",
|
||||
vatLabel: (ratePercent) => `zzgl. ${ratePercent} % Umsatzsteuer`,
|
||||
smallBusinessNote: "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.",
|
||||
totalLabel: "Gesamt",
|
||||
legalNote:
|
||||
"Kleinunternehmerregelung / Umsatzsteuerausweis gemäß tatsächlichem Status ergänzen. Diese Rechnung wurde automatisch erzeugt und ist ohne Unterschrift gültig.",
|
||||
legalNote: "Diese Rechnung wurde automatisch erzeugt und ist ohne Unterschrift gültig.",
|
||||
},
|
||||
addressesPage: {
|
||||
title: "Meine Adressen",
|
||||
|
||||
@@ -1227,9 +1227,11 @@ export const en: Dictionary = {
|
||||
shippingLabel: "Shipping",
|
||||
freeShipping: "free",
|
||||
discountLabel: "Discount",
|
||||
netLabel: "Net amount",
|
||||
vatLabel: (ratePercent) => `plus ${ratePercent}% VAT`,
|
||||
smallBusinessNote: "No VAT is charged (small-business scheme, § 19 German VAT Act).",
|
||||
totalLabel: "Total",
|
||||
legalNote:
|
||||
"Small-business tax exemption / VAT disclosure to be added according to actual status. This invoice was generated automatically and is valid without a signature.",
|
||||
legalNote: "This invoice was generated automatically and is valid without a signature.",
|
||||
},
|
||||
addressesPage: {
|
||||
title: "My Addresses",
|
||||
|
||||
@@ -914,6 +914,9 @@ export interface Dictionary {
|
||||
shippingLabel: string;
|
||||
freeShipping: string;
|
||||
discountLabel: string;
|
||||
netLabel: string;
|
||||
vatLabel: (ratePercent: number) => string;
|
||||
smallBusinessNote: string;
|
||||
totalLabel: string;
|
||||
legalNote: string;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import PDFDocument from "pdfkit";
|
||||
import { formatPrice, formatDate } from "@/lib/format";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { splitGross } from "@/lib/vat";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
|
||||
interface InvoicePdfInput {
|
||||
@@ -9,6 +10,13 @@ interface InvoicePdfInput {
|
||||
invoiceNumber: string;
|
||||
issuedAt: Date;
|
||||
totalCents: number;
|
||||
/**
|
||||
* Umsatzsteuersatz in ganzen Prozent, wie er an der Rechnung
|
||||
* festgeschrieben wurde. 0 = Kleinunternehmerregelung (§ 19 UStG), null =
|
||||
* Rechnung aus der Zeit vor der Steuerausweisung — dann bleibt der Block
|
||||
* weg, weil rueckwirkend nicht feststellbar ist, welcher Satz gegolten hat.
|
||||
*/
|
||||
vatRatePercent?: number | null;
|
||||
order: {
|
||||
orderNumber: string;
|
||||
firstName: string;
|
||||
@@ -116,10 +124,31 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer
|
||||
.fillColor("#242424")
|
||||
.text(`−${formatPrice(input.order.discountCents, input.order.currency)}`, columns.total, y);
|
||||
}
|
||||
// Steuerausweis. Der Satz steht an der Rechnung selbst (nicht in der
|
||||
// Umgebung), damit ein alter Beleg auch nach einem Wechsel des
|
||||
// Steuerstatus unveraendert bleibt. Null = Rechnung aus der Zeit vor der
|
||||
// Steuerausweisung: dann bleibt der Block weg wie bisher.
|
||||
if (input.vatRatePercent != null && input.vatRatePercent > 0) {
|
||||
const { netCents, vatCents } = splitGross(input.totalCents, input.vatRatePercent);
|
||||
y += 16;
|
||||
doc.fillColor("#666666").text(t.netLabel, 350, y);
|
||||
doc.fillColor("#242424").text(formatPrice(netCents, input.order.currency), columns.total, y);
|
||||
y += 16;
|
||||
doc.fillColor("#666666").text(t.vatLabel(input.vatRatePercent), 350, y);
|
||||
doc.fillColor("#242424").text(formatPrice(vatCents, input.order.currency), columns.total, y);
|
||||
}
|
||||
|
||||
y += 20;
|
||||
doc.fontSize(11).fillColor("#24352B").text(t.totalLabel, 350, y);
|
||||
doc.text(formatPrice(input.totalCents, input.order.currency), columns.total, y);
|
||||
|
||||
// Der Hinweis nach § 19 UStG ist bei Kleinunternehmerregelung eine
|
||||
// Pflichtangabe — er steht anstelle der Steuerzeilen, nicht zusaetzlich.
|
||||
if (input.vatRatePercent === 0) {
|
||||
y += 22;
|
||||
doc.fontSize(9).fillColor("#666666").text(t.smallBusinessNote, 350, y, { width: 195 });
|
||||
}
|
||||
|
||||
doc
|
||||
.fontSize(8)
|
||||
.fillColor("#999999")
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, test, expect, vi, afterEach } from "vitest";
|
||||
import { splitGross, vatRatePercent, DEFAULT_VAT_RATE_PERCENT } from "./vat";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("vatRatePercent", () => {
|
||||
test("nimmt einen gueltigen Satz", () => {
|
||||
expect(vatRatePercent("7")).toBe(7);
|
||||
expect(vatRatePercent("19")).toBe(19);
|
||||
expect(vatRatePercent(" 19 ")).toBe(19);
|
||||
});
|
||||
|
||||
test("laesst 0 zu — das ist die Kleinunternehmerregelung", () => {
|
||||
// Arrange & Act & Assert: 0 ist ein gueltiger Wert und darf NICHT als
|
||||
// "nicht gesetzt" behandelt werden, sonst waere § 19 UStG nicht abbildbar.
|
||||
expect(vatRatePercent("0")).toBe(0);
|
||||
});
|
||||
|
||||
test("faellt ohne Angabe auf den ermaessigten Satz zurueck", () => {
|
||||
expect(vatRatePercent(undefined)).toBe(DEFAULT_VAT_RATE_PERCENT);
|
||||
expect(vatRatePercent("")).toBe(DEFAULT_VAT_RATE_PERCENT);
|
||||
expect(vatRatePercent(" ")).toBe(DEFAULT_VAT_RATE_PERCENT);
|
||||
});
|
||||
|
||||
test("meldet einen unbrauchbaren Wert laut, statt still zu rechnen", () => {
|
||||
// Arrange: ein falscher Steuersatz darf nicht unbemerkt bleiben.
|
||||
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Act
|
||||
const rate = vatRatePercent("7,5");
|
||||
|
||||
// Assert
|
||||
expect(rate).toBe(DEFAULT_VAT_RATE_PERCENT);
|
||||
expect(logged).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("weist Kommazahlen, negative Werte und Unsinn ab", () => {
|
||||
// Arrange
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Act & Assert
|
||||
expect(vatRatePercent("7.5")).toBe(DEFAULT_VAT_RATE_PERCENT);
|
||||
expect(vatRatePercent("-19")).toBe(DEFAULT_VAT_RATE_PERCENT);
|
||||
expect(vatRatePercent("101")).toBe(DEFAULT_VAT_RATE_PERCENT);
|
||||
expect(vatRatePercent("neunzehn")).toBe(DEFAULT_VAT_RATE_PERCENT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitGross", () => {
|
||||
test("rechnet die Steuer aus dem Bruttobetrag heraus", () => {
|
||||
// Arrange & Act: 10,70 EUR brutto bei 7% sind 10,00 netto und 0,70 Steuer.
|
||||
const split = splitGross(1070, 7);
|
||||
|
||||
// Assert
|
||||
expect(split).toEqual({ netCents: 1000, vatCents: 70 });
|
||||
});
|
||||
|
||||
test("rechnet auch beim vollen Satz heraus, statt aufzuschlagen", () => {
|
||||
// Arrange & Act & Assert: 11,90 brutto bei 19% sind 10,00 netto. Ein
|
||||
// Aufschlag ergaebe 14,16 und waere ein anderer Endpreis als der, den die
|
||||
// Kundschaft im Warenkorb gesehen hat.
|
||||
expect(splitGross(1190, 19)).toEqual({ netCents: 1000, vatCents: 190 });
|
||||
});
|
||||
|
||||
test("netto plus Steuer ergibt immer exakt den Bruttobetrag", () => {
|
||||
// Arrange: krumme Betraege, bei denen zwei getrennte Rundungen
|
||||
// auseinanderlaufen wuerden.
|
||||
const amounts = [1, 99, 999, 1234, 4567, 98765];
|
||||
|
||||
for (const rate of [7, 19]) {
|
||||
for (const gross of amounts) {
|
||||
// Act
|
||||
const { netCents, vatCents } = splitGross(gross, rate);
|
||||
|
||||
// Assert: eine Rechnung, die sich nicht aufaddiert, ist keine
|
||||
// brauchbare Rechnung.
|
||||
expect(netCents + vatCents).toBe(gross);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("weist bei Satz 0 keine Steuer aus", () => {
|
||||
// Arrange & Act & Assert: Kleinunternehmerregelung — der Bruttobetrag ist
|
||||
// zugleich der Nettobetrag.
|
||||
expect(splitGross(1070, 0)).toEqual({ netCents: 1070, vatCents: 0 });
|
||||
});
|
||||
|
||||
test("kommt mit dem Betrag null zurecht", () => {
|
||||
expect(splitGross(0, 19)).toEqual({ netCents: 0, vatCents: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Umsatzsteuer auf Rechnungen.
|
||||
*
|
||||
* Der Satz kommt aus VAT_RATE_PERCENT und ist bewusst konfigurierbar statt
|
||||
* fest verdrahtet: er haengt vom Steuerstatus des Betriebs ab und kann sich
|
||||
* aendern, ohne dass dafuer ein Deploy noetig sein sollte.
|
||||
*
|
||||
* DIE PREISE IM SHOP SIND BRUTTOPREISE. Gegenueber Verbraucherinnen und
|
||||
* Verbrauchern ist der Gesamtpreis inklusive Umsatzsteuer anzugeben (§ 1
|
||||
* PAngV), und genau dieser Betrag steht in Product.priceCents und in
|
||||
* Order.totalCents. Die Steuer wird daher HERAUSGERECHNET, nicht
|
||||
* aufgeschlagen — ein Aufschlag wuerde den Endpreis veraendern und damit von
|
||||
* dem abweichen, was die Kundschaft im Warenkorb gesehen und bezahlt hat.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ermaessigter Satz. Als Vorgabe gewaehlt, weil Honig als Lebensmittel
|
||||
* darunter faellt (Anlage 2 zum UStG) und den Hauptteil des Sortiments
|
||||
* ausmacht.
|
||||
*
|
||||
* ACHTUNG: Kerzen sind KEIN Lebensmittel und unterliegen dem vollen Satz.
|
||||
* Solange es nur diesen einen Wert gibt, ist er fuer solche Artikel zu
|
||||
* niedrig — siehe README-Abschnitt "Rechnungen".
|
||||
*/
|
||||
export const DEFAULT_VAT_RATE_PERCENT = 7;
|
||||
|
||||
/**
|
||||
* Liest den Steuersatz aus der Umgebung.
|
||||
*
|
||||
* Erlaubt sind ganze Prozentwerte von 0 bis 100. 0 bedeutet: keine
|
||||
* Umsatzsteuer, also Kleinunternehmerregelung nach § 19 UStG — die Rechnung
|
||||
* traegt dann statt der Steuerzeilen den dafuer vorgeschriebenen Hinweis.
|
||||
*
|
||||
* Ein unbrauchbarer Wert faellt auf die Vorgabe zurueck UND wird laut
|
||||
* geloggt, statt den Vorgang abzubrechen: die Steuerausweisung darf keine
|
||||
* bereits bezahlte Bestellung scheitern lassen. Damit ein falscher Wert
|
||||
* trotzdem nicht unbemerkt bleibt, prueft ihn zusaetzlich "pnpm preflight",
|
||||
* und der verwendete Satz steht auf jeder Rechnung.
|
||||
*
|
||||
* Nur ganze Zahlen: die deutschen Saetze sind ganzzahlig (7, 19), und ein
|
||||
* versehentlich mit Komma geschriebenes "7,5" wuerde sonst still als 7
|
||||
* durchgehen.
|
||||
*/
|
||||
export function vatRatePercent(raw: string | undefined): number {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return DEFAULT_VAT_RATE_PERCENT;
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) {
|
||||
console.error(
|
||||
`[vat] VAT_RATE_PERCENT="${raw}" ist kein ganzzahliger Prozentwert zwischen 0 und 100 — es wird mit ${DEFAULT_VAT_RATE_PERCENT}% gerechnet.`
|
||||
);
|
||||
return DEFAULT_VAT_RATE_PERCENT;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export interface VatSplit {
|
||||
/** Nettobetrag in Cent. */
|
||||
netCents: number;
|
||||
/** Steuerbetrag in Cent. */
|
||||
vatCents: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt einen Bruttobetrag in Netto und Steuer.
|
||||
*
|
||||
* Der Steuerbetrag ergibt sich als REST (brutto minus netto), nicht aus einer
|
||||
* zweiten Rundung. Nur so gilt immer netto + steuer === brutto; zwei getrennt
|
||||
* gerundete Werte koennen um einen Cent danebenliegen, und eine Rechnung, die
|
||||
* sich nicht aufaddiert, ist keine brauchbare Rechnung.
|
||||
*
|
||||
* Gerechnet wird auf der GESAMTSUMME, nicht je Position: Rundungsfehler
|
||||
* einzelner Zeilen wuerden sich sonst aufsummieren. Der Versand traegt dabei
|
||||
* den Satz der Ware — er ist umsatzsteuerlich eine Nebenleistung und folgt
|
||||
* der Hauptleistung.
|
||||
*/
|
||||
export function splitGross(grossCents: number, ratePercent: number): VatSplit {
|
||||
if (ratePercent <= 0) return { netCents: grossCents, vatCents: 0 };
|
||||
|
||||
const netCents = Math.round(grossCents / (1 + ratePercent / 100));
|
||||
return { netCents, vatCents: grossCents - netCents };
|
||||
}
|
||||
Reference in new issue
Block a user