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
@@ -214,6 +214,21 @@ UPLOAD_DIR=./uploads
|
||||
# k8s/04-uploads-pvc.yaml und den Sicherungs-CronJob k8s/07-backup-cronjob.yaml.
|
||||
INVOICE_DIR=./invoices
|
||||
|
||||
# Umsatzsteuersatz in GANZEN Prozent, ausgewiesen auf jeder Rechnung.
|
||||
# 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG)
|
||||
# 19 = voller Satz
|
||||
# 0 = Kleinunternehmerregelung; die Rechnung traegt dann statt der
|
||||
# Steuerzeilen den Pflichthinweis nach § 19 UStG
|
||||
#
|
||||
# Die Shop-Preise sind BRUTTOPREISE (gegenueber Verbrauchern ist der
|
||||
# Gesamtpreis inkl. Umsatzsteuer anzugeben, § 1 PAngV). Die Steuer wird daraus
|
||||
# HERAUSGERECHNET, nicht aufgeschlagen — der Endpreis bleibt genau der, den die
|
||||
# Kundschaft im Warenkorb gesehen und bezahlt hat.
|
||||
#
|
||||
# Der Satz wird an jeder Rechnung festgeschrieben: eine Aenderung hier wirkt
|
||||
# nur auf kuenftige Rechnungen, bereits ausgestellte behalten ihren Satz.
|
||||
VAT_RATE_PERCENT=7
|
||||
|
||||
# Threads, die ffmpeg beim Umrechnen eines hochgeladenen Videos benutzen darf
|
||||
# (Admin -> Inhalte -> Hero-Video). Leer lassen heisst 2, erlaubt sind 1 bis 8.
|
||||
#
|
||||
|
||||
@@ -274,10 +274,23 @@ Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein abge
|
||||
|
||||
> Bei **mehr als einem Knoten**: das PVC `wabenhain-invoices` ist `ReadWriteOnce` und lässt sich nur von Pods auf demselben Knoten einhängen. Landet der Sicherungs-Pod woanders als die App, bleibt er in „Pending" — ohne Fehlermeldung. Dann das PVC auf `ReadWriteMany` umstellen (NFS/SMB/CephFS).
|
||||
|
||||
**Noch offen** und vor dem Live-Gang zu klären:
|
||||
### Umsatzsteuer
|
||||
|
||||
- Die Rechnungsnummer (`RE-2026-<8 Hex>`) ist eindeutig, aber **nicht fortlaufend**. § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende Nummer.
|
||||
- Das PDF weist **keine Umsatzsteuer** aus — weder Satz und Betrag noch den Hinweis nach § 19 UStG bei Kleinunternehmerregelung. Welches von beidem nötig ist, hängt vom Steuerstatus der Imkerei ab.
|
||||
Der Satz kommt aus `VAT_RATE_PERCENT` (ganze Prozent) und steht auf Rechnungsansicht und PDF:
|
||||
|
||||
| Wert | Bedeutung | Auf der Rechnung |
|
||||
|------|-----------|------------------|
|
||||
| `7` | ermäßigt — Honig als Lebensmittel, Anlage 2 zum UStG | Nettobetrag + „zzgl. 7 % Umsatzsteuer" |
|
||||
| `19` | voller Satz | Nettobetrag + „zzgl. 19 % Umsatzsteuer" |
|
||||
| `0` | Kleinunternehmerregelung | „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet." anstelle der Steuerzeilen |
|
||||
|
||||
**Die Shop-Preise sind Bruttopreise.** Gegenüber Verbraucher:innen 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 würde den Endpreis verändern und von dem abweichen, was im Warenkorb stand. Gerechnet wird auf der Gesamtsumme, damit sich Rundungen einzelner Positionen nicht aufsummieren; der Steuerbetrag ergibt sich als Rest, sodass Netto + Steuer immer exakt den Bruttobetrag ergibt. Der Versand trägt den Satz der Ware (umsatzsteuerliche Nebenleistung).
|
||||
|
||||
Der Satz wird bei jeder Rechnung als `Invoice.vatRatePercent` **festgeschrieben**. Ändert sich der Steuerstatus, gilt der neue Wert nur für künftige Rechnungen — ein Beleg über einen vergangenen Vorgang darf sich nicht rückwirkend ändern. `pnpm preflight` blockiert einen fehlenden oder unplausiblen Wert.
|
||||
|
||||
> **Ein Satz für alle Artikel.** Honig ist ermäßigt besteuert, Kerzen nicht. Werden beide verkauft, ist der eine Wert zwangsläufig für eine der beiden Gruppen falsch — dafür bräuchte es einen Satz je `ProductCategory`. Solange nur Honig verkauft wird, genügt `7`.
|
||||
|
||||
**Noch offen:** die Rechnungsnummer (`RE-2026-<8 Hex>`) ist eindeutig, aber **nicht fortlaufend**. § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende Nummer.
|
||||
|
||||
## Health-Endpunkte
|
||||
|
||||
|
||||
@@ -62,6 +62,20 @@ data:
|
||||
# src/lib/invoice-storage.ts und 04-uploads-pvc.yaml.
|
||||
INVOICE_DIR: "/app/invoices"
|
||||
|
||||
# Umsatzsteuersatz in GANZEN Prozent, der auf der Rechnung ausgewiesen wird.
|
||||
# 7 = ermaessigter Satz (Honig als Lebensmittel, Anlage 2 zum UStG)
|
||||
# 19 = voller Satz
|
||||
# 0 = Kleinunternehmerregelung; die Rechnung traegt dann statt der
|
||||
# Steuerzeilen den Pflichthinweis nach § 19 UStG
|
||||
#
|
||||
# Die Shop-Preise sind BRUTTOPREISE (§ 1 PAngV). Die Steuer wird daraus
|
||||
# herausgerechnet, nicht aufgeschlagen — der Endpreis bleibt also genau der,
|
||||
# den die Kundschaft im Warenkorb gesehen hat.
|
||||
#
|
||||
# Der Satz wird bei jeder Rechnung FESTGESCHRIEBEN. Eine Aenderung hier gilt
|
||||
# nur fuer kuenftige Rechnungen; bereits ausgestellte behalten ihren Satz.
|
||||
VAT_RATE_PERCENT: "7"
|
||||
|
||||
# Threads fuer das Umrechnen hochgeladener Videos (Admin -> Inhalte ->
|
||||
# Hero-Video). NICHT ohne Not erhoehen: ffmpeg teilt sich das Speicherlimit
|
||||
# dieses Pods mit dem Node-Prozess, und jeder zusaetzliche Thread haelt
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Umsatzsteuersatz in ganzen Prozent, festgeschrieben zum Zeitpunkt der
|
||||
-- Rechnungsstellung (0 = Kleinunternehmerregelung nach § 19 UStG).
|
||||
--
|
||||
-- Nullable, weil rueckwirkend nicht feststellbar ist, welcher Satz fuer
|
||||
-- bereits bestehende Rechnungen richtig gewesen waere. Diese zeigen wie
|
||||
-- bisher keine Steuerzeilen; erst ab hier ausgestellte Rechnungen weisen
|
||||
-- die Steuer aus.
|
||||
-- AlterTable
|
||||
ALTER TABLE "invoices" ADD COLUMN "vatRatePercent" INTEGER;
|
||||
@@ -524,6 +524,19 @@ model Invoice {
|
||||
invoiceNumber String @unique
|
||||
issuedAt DateTime @default(now())
|
||||
totalCents Int
|
||||
/// Umsatzsteuersatz in ganzen Prozent, wie er zum Zeitpunkt der
|
||||
/// Rechnungsstellung galt (0 = Kleinunternehmerregelung nach § 19 UStG).
|
||||
///
|
||||
/// Wird FESTGESCHRIEBEN statt bei jeder Anzeige aus VAT_RATE_PERCENT
|
||||
/// gelesen: aendert sich der Steuerstatus des Betriebs, muessen alte
|
||||
/// Rechnungen weiterhin den damals ausgewiesenen Satz zeigen. Eine Rechnung
|
||||
/// ist ein Beleg ueber einen vergangenen Vorgang, keine Ansicht auf die
|
||||
/// heutige Konfiguration.
|
||||
///
|
||||
/// Null bei Rechnungen aus der Zeit vor der Steuerausweisung — die zeigen
|
||||
/// weiterhin keine Steuerzeilen, weil rueckwirkend nicht feststellbar ist,
|
||||
/// welcher Satz damals richtig gewesen waere.
|
||||
vatRatePercent Int?
|
||||
/// Pfad des abgelegten PDFs, RELATIV zu INVOICE_DIR — z.B.
|
||||
/// "2026/mustermann-erika/RE-2026-A3F9C1B2.pdf". Siehe lib/invoice-storage.ts.
|
||||
///
|
||||
|
||||
@@ -113,6 +113,23 @@ should(
|
||||
"Absenderadressen fremder Domains landen regelmaessig im Spam-Ordner."
|
||||
);
|
||||
|
||||
// --- Umsatzsteuer ----------------------------------------------------------
|
||||
// Ein falscher Steuersatz faellt im Betrieb kaum auf: die Rechnung sieht
|
||||
// plausibel aus, nur die ausgewiesene Steuer stimmt nicht. Deshalb hier
|
||||
// ausdruecklich pruefen, statt sich auf den Rueckfallwert zu verlassen.
|
||||
const vatRate = value("VAT_RATE_PERCENT");
|
||||
const vatRateNumber = Number(vatRate);
|
||||
must(
|
||||
"VAT_RATE_PERCENT gesetzt und ein ganzzahliger Prozentwert",
|
||||
vatRate.length > 0 && Number.isInteger(vatRateNumber) && vatRateNumber >= 0 && vatRateNumber <= 100,
|
||||
"Wird auf jeder Rechnung ausgewiesen. 7 = ermaessigt (Honig), 19 = voll, 0 = Kleinunternehmerregelung nach § 19 UStG. Ohne gueltigen Wert rechnet die Anwendung mit 7% weiter — fuer Kerzen zu niedrig, fuer Kleinunternehmer schlicht falsch."
|
||||
);
|
||||
should(
|
||||
"Umsatzsteuersatz ist ein in Deutschland ueblicher Wert",
|
||||
["0", "7", "19"].includes(vatRate),
|
||||
`Eingetragen ist "${vatRate}". Ueblich sind 0 (§ 19 UStG), 7 (ermaessigt) oder 19 (voll) — ein anderer Wert ist moeglich, aber pruefenswert.`
|
||||
);
|
||||
|
||||
// --- Datenbank -------------------------------------------------------------
|
||||
must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht.");
|
||||
|
||||
|
||||
@@ -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