Add Kleinunternehmerregelung (§19 UStG) support end-to-end

Checkout forces 0% VAT without de-grossing prices when the tenant is a
Kleinunternehmer (a business decision, not just an engineering default —
unlike the existing intra-community VAT exemption, which does de-gross).
Snapshotted onto the order at checkout time so a later toggle of the
company-settings checkbox never rewrites an already-issued invoice's tax
treatment — same reasoning as the existing vatExempt field.

Threaded through: checkout route, order creation/confirmation email,
on-demand invoice/Storno/Gutschrift downloads, the Bestellbestätigung
page, and the account order-detail page. The four storefront "inkl. X%
MwSt." price hints (shop grid, cart upsell, ToDo-Karten landing page,
homepage spotlight) drop that clause live when the setting is on. The
company-settings Live Preview reflects the checkbox in real time too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-24 15:08:13 +00:00
parent 89dd11bf77
commit ba830947d2
23 changed files with 203 additions and 35 deletions
+1
View File
@@ -455,6 +455,7 @@ export type CustomerOrderDetail = CustomerOrder & {
companyName: string | null;
vatId: string | null;
vatExempt: boolean;
kleinunternehmer: boolean;
vatIdValidatedAt: string | null;
deliveryMethod: "address" | "packstation";
street: string | null;
+6
View File
@@ -24,4 +24,10 @@ export type OrderSnapshot = {
* the exempt (net, de-grossed) totals instead of the normal VAT-
* inclusive catalog prices it would otherwise re-derive live. */
vatExempt: boolean;
/** §19 UStG — this tenant's company-settings.kleinunternehmer as it stood
* at checkout time (see api/checkout/route.ts), never re-derived live —
* takes precedence over vatExempt above wherever both would otherwise
* apply. /bestellbestaetigung uses this to show the §19 notice instead
* of a per-item "inkl. X% MwSt." hint/VAT breakdown. */
kleinunternehmer: boolean;
};
+2
View File
@@ -16,6 +16,7 @@ export type OrderConfirmationEmailData = OrderConfirmationData & {
companyName?: string | null;
vatId?: string | null;
vatExempt?: boolean;
kleinunternehmer?: boolean;
deliveryMethod: "address" | "packstation";
street?: string | null;
packstationNumber?: string | null;
@@ -74,6 +75,7 @@ export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailDa
companyName: order.companyName,
vatId: order.vatId,
vatExempt: order.vatExempt,
kleinunternehmer: order.kleinunternehmer,
deliveryMethod: order.deliveryMethod,
street: order.street,
packstationNumber: order.packstationNumber,
+5
View File
@@ -41,6 +41,10 @@ export type CreateOrderInput = {
// Decided server-side in api/checkout/route.ts (a live VIES check at the
// moment of purchase, never guessed) — see Orders.ts's own comment.
vatExempt: boolean;
// §19 UStG — this tenant's company-settings.kleinunternehmer as read at
// the moment of purchase, snapshotted onto the order (same reasoning as
// vatExempt above, plus Orders.ts's own field comment).
kleinunternehmer: boolean;
vatIdValidatedAt: string | null;
deliveryMethod: "address" | "packstation";
street?: string;
@@ -98,6 +102,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreatedOrder
companyName: input.companyName,
vatId: input.vatId,
vatExempt: input.vatExempt,
kleinunternehmer: input.kleinunternehmer,
vatIdValidatedAt: input.vatIdValidatedAt,
deliveryMethod: input.deliveryMethod,
street: input.street,
+27
View File
@@ -800,6 +800,14 @@ export type CompanySettings = {
sellerEmail: string;
vatId: string;
taxRatePercent: number;
// Kleinunternehmerregelung (§19 UStG) — when true, checkout forces every
// order's items to 0% VAT (never de-grossed, unlike the intra-community
// exemption) and the tax rate above is ignored. Read live only at
// checkout time (see api/checkout/route.ts) to decide what to snapshot
// onto the new order — never read live when rendering an existing
// order's invoice, see OrderSnapshot/CustomerOrderDetail's own
// `kleinunternehmer` field for why.
kleinunternehmer: boolean;
iban: string | null;
bic: string | null;
};
@@ -843,3 +851,22 @@ export async function getDefaultTaxRatePercent(): Promise<number> {
const data: { docs?: { taxRatePercent: number }[] } = await res.json();
return data.docs?.[0]?.taxRatePercent ?? 19;
}
// Same ISR-cached, public-catalog-freshness fetch as getDefaultTaxRatePercent()
// above (a separate round trip rather than reusing getCompanySettings()'s
// deliberate cache: "no-store") — powers the "inkl. X% MwSt." storefront
// hints (dropped entirely when this is true, see ProductGrid.tsx/
// ProductSpotlight.tsx/etc.) and the cart/checkout VAT-breakdown display.
export async function getKleinunternehmer(): Promise<boolean> {
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getKleinunternehmer: Payload returned ${res.status} ${res.statusText}`);
return false;
}
const data: { docs?: { kleinunternehmer: boolean }[] } = await res.json();
return data.docs?.[0]?.kleinunternehmer ?? false;
}