From ba830947d27d2e095c7bcd6ad86ece09c61b0e81 Mon Sep 17 00:00:00 2001
From: Marco
Date: Fri, 24 Jul 2026 15:08:13 +0000
Subject: [PATCH] =?UTF-8?q?Add=20Kleinunternehmerregelung=20(=C2=A719=20US?=
=?UTF-8?q?tG)=20support=20end-to-end?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
README.md | 62 +++++++++++++++++++
.../[orderNumber]/correction-invoice/route.ts | 1 +
.../orders/[orderNumber]/invoice/route.ts | 1 +
app/api/checkout/route.ts | 21 ++++++-
.../components/BestellbestaetigungContent.tsx | 9 ++-
app/cart/components/CartContent.tsx | 14 ++++-
app/cart/components/RelatedProducts.tsx | 4 +-
app/cart/page.tsx | 8 ++-
app/checkout/components/CheckoutContent.tsx | 15 ++++-
app/checkout/page.tsx | 6 +-
.../LiveCompanySettingsPreviewClient.tsx | 7 ++-
app/company-settings-preview/page.tsx | 1 +
app/components/ProductSpotlight.tsx | 7 ++-
app/konto/bestellungen/[orderNumber]/page.tsx | 12 +++-
app/lib/customerAuth.ts | 1 +
app/lib/order.ts | 6 ++
app/lib/orderEmail.ts | 2 +
app/lib/orderServer.ts | 5 ++
app/lib/payload.ts | 27 ++++++++
app/shop/components/ProductGrid.tsx | 11 +++-
app/todo-cards/components/Pricing.tsx | 7 ++-
app/todo-cards/components/TodoKartenHero.tsx | 7 ++-
package-lock.json | 4 +-
23 files changed, 203 insertions(+), 35 deletions(-)
diff --git a/README.md b/README.md
index 607ea83..f6588d7 100644
--- a/README.md
+++ b/README.md
@@ -327,6 +327,11 @@ thumbnails per order row (`getProductImagesByIds()` in `app/lib/payload.ts`,
a plain product-id → image-url lookup separate from the slug-keyed
catalog, since an order only ever snapshots a numeric product id).
+**None of this renders at all for a Kleinunternehmer tenant** (built
+2026-07-24) — see "Kleinunternehmerregelung" below for the full list of
+touched spots and why some read a live setting and others a persisted
+per-order snapshot.
+
### Checkout state persistence
`app/lib/checkoutDraft.ts` — `localStorage` under `ep_checkout_draft`,
@@ -833,6 +838,63 @@ turns out unregistered, the seller retroactively owes the VAT itself).
live from the current catalog, which would show the wrong, VAT-inclusive
figures for an exempt order).
+### Kleinunternehmerregelung (§19 UStG)
+
+Built 2026-07-24. `company-settings.kleinunternehmer` — a standing
+per-tenant setting (Payload backend), not a per-order decision like the
+VAT exemption above — when on, this tenant never charges VAT on anything,
+domestic or cross-border. See the Payload README's own
+"Kleinunternehmerregelung" section for the field/collection side; this one
+covers what changed here.
+
+- **`api/checkout/route.ts`** forces every item's `taxRatePercent` to `0`
+ when `getCompanySettings().kleinunternehmer` is on — deliberately
+ **without** de-grossing `unitPrice` the way `vatExempt` above does
+ (that exemption zero-rates what would otherwise be a positive-rate
+ charge, so de-grossing means the buyer pays less; a Kleinunternehmer
+ never charged VAT on the sale to begin with, so the catalog gross price
+ already *is* the actual net charge — confirmed with the user as the
+ intended business decision, not an engineering default). The VIES
+ lookup/`isExemptionEligibleCountry()` check is skipped entirely in this
+ branch too — there's no VAT for the intra-community rule to exempt
+ either. Snapshotted onto the new order as `Orders.kleinunternehmer`
+ (mirrors `vatExempt`'s own snapshot reasoning — see the Payload README).
+- **Storefront "inkl. X% MwSt." hints** — four spots read the *live*
+ setting (`getKleinunternehmer()` in `app/lib/payload.ts`, same ISR-cached
+ 60s freshness as `getDefaultTaxRatePercent()`) and drop the MwSt. clause
+ entirely when it's on, since there's no order yet at that point to
+ snapshot from: `ProductGrid.tsx` (shop grid), `RelatedProducts.tsx`
+ (cart's upsell row), `Pricing.tsx`/`TodoKartenHero.tsx` (ToDo-Karten
+ landing page), `ProductSpotlight.tsx` (homepage). `Pricing.tsx`/
+ `ProductSpotlight.tsx` keep "zzgl. Versand" on its own when the MwSt.
+ clause drops; the other two had no such trailing clause to preserve.
+- **Every already-placed-order display reads the persisted snapshot
+ instead** — `OrderSnapshot.kleinunternehmer` (`app/lib/order.ts`,
+ written into `sessionStorage` at checkout, read by
+ `BestellbestaetigungContent.tsx`) and `CustomerOrderDetail.
+ kleinunternehmer` (`app/lib/customerAuth.ts`, read by
+ `/konto/bestellungen/[orderNumber]`) — never the live company-settings
+ value, for the identical "don't retroactively rewrite an already-issued
+ invoice's tax treatment" reason `vatExempt` already established. Both
+ pages replace the per-item "inkl. X% MwSt." hint and the `VatBreakdown`
+ summary with "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet." —
+ taking precedence over the `vatExempt` note wherever both would
+ otherwise apply. `CheckoutContent.tsx`'s live VIES-exemption *preview*
+ is also gated off (`!kleinunternehmer && ...`) so a Kleinunternehmer
+ tenant never shows a misleading "wird steuerfrei berechnet" preview for
+ VAT that was never going to be charged either way.
+- **On-demand invoice/Stornorechnung/Gutschrift downloads**
+ (`api/account/orders/[orderNumber]/invoice/route.ts` and its
+ `correction-invoice` sibling) thread `order.kleinunternehmer` through to
+ `@einfach-produktiv/invoicing`'s renderers the same way they already
+ thread `vatExempt`.
+- **`app/company-settings-preview`'s Live Preview** merges the live-edited
+ `kleinunternehmer` checkbox onto the fixed `SAMPLE_INVOICE_ORDER` before
+ rendering (`kleinunternehmer` lives on `InvoiceOrder`, not
+ `InvoiceSeller` — see the invoicing package's own README on why), so an
+ admin sees the §19 UStG notice appear/disappear live as they toggle the
+ field, without this preview needing its own separate mechanism.
+
### Company Settings & Live Preview
`company-settings` has a Live Preview button too, like `email-templates`
diff --git a/app/api/account/orders/[orderNumber]/correction-invoice/route.ts b/app/api/account/orders/[orderNumber]/correction-invoice/route.ts
index 7012494..ccb75a6 100644
--- a/app/api/account/orders/[orderNumber]/correction-invoice/route.ts
+++ b/app/api/account/orders/[orderNumber]/correction-invoice/route.ts
@@ -37,6 +37,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde
companyName: order.companyName,
vatId: order.vatId,
vatExempt: order.vatExempt,
+ kleinunternehmer: order.kleinunternehmer,
deliveryMethod: order.deliveryMethod,
street: order.street,
packstationNumber: order.packstationNumber,
diff --git a/app/api/account/orders/[orderNumber]/invoice/route.ts b/app/api/account/orders/[orderNumber]/invoice/route.ts
index eb22bde..ef85815 100644
--- a/app/api/account/orders/[orderNumber]/invoice/route.ts
+++ b/app/api/account/orders/[orderNumber]/invoice/route.ts
@@ -34,6 +34,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ orde
companyName: order.companyName,
vatId: order.vatId,
vatExempt: order.vatExempt,
+ kleinunternehmer: order.kleinunternehmer,
deliveryMethod: order.deliveryMethod,
street: order.street,
packstationNumber: order.packstationNumber,
diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts
index ec03c62..dfcad22 100644
--- a/app/api/checkout/route.ts
+++ b/app/api/checkout/route.ts
@@ -141,6 +141,15 @@ export async function POST(request: Request) {
// Re-price everything server-side — never trust client-submitted prices.
const [productsBySlug, companySettings] = await Promise.all([fetchProductsBySlug(), getCompanySettings()]);
const defaultTaxRate = companySettings?.taxRatePercent ?? 19;
+ // §19 UStG — a Kleinunternehmer tenant never charges VAT on anything,
+ // full stop, so every item's tax rate is forced to 0% here regardless of
+ // its own catalog/company-settings default rate. Unlike the
+ // intra-community exemption below, prices are NOT de-grossed — see
+ // Orders.ts's own kleinunternehmer field comment and this shop's
+ // Kleinunternehmer decision: catalog gross prices stay exactly what they
+ // are, they simply never had a VAT component charged on top in the
+ // first place.
+ const kleinunternehmer = Boolean(companySettings?.kleinunternehmer);
const items: {
productId: number;
productName: string;
@@ -182,7 +191,7 @@ export async function POST(request: Request) {
quantity: line.qty,
unitPrice: variant?.priceOverride ?? product.price,
imageUrl,
- taxRatePercent: product.taxRatePercent ?? defaultTaxRate,
+ taxRatePercent: kleinunternehmer ? 0 : (product.taxRatePercent ?? defaultTaxRate),
bundleContents: describeBundleContents(product),
variantName: variant?.name ?? null,
});
@@ -227,8 +236,13 @@ export async function POST(request: Request) {
// unset in that case too).
let vatExempt = false;
let vatIdValidatedAt: string | null = null;
+ // A Kleinunternehmer never charges VAT on any sale, domestic or
+ // cross-border — the intra-community exemption exists to zero-rate what
+ // would otherwise be a positive-rate charge, which never applies here in
+ // the first place, so the VIES lookup is skipped entirely (also saves an
+ // unneeded network round-trip).
const buyerDestinationCountry = destinationCountry(body.country, Boolean(body.hasDifferentShippingAddress), body.shippingCountry);
- if (normalizedVatId) {
+ if (!kleinunternehmer && normalizedVatId) {
const viesResult = await checkVatIdViaVies(normalizedVatId);
if (viesResult.ok && viesResult.valid) {
vatIdValidatedAt = new Date().toISOString();
@@ -275,6 +289,7 @@ export async function POST(request: Request) {
companyName: body.companyName || undefined,
vatId: normalizedVatId,
vatExempt,
+ kleinunternehmer,
vatIdValidatedAt,
deliveryMethod: body.deliveryMethod,
street: body.street,
@@ -335,6 +350,7 @@ export async function POST(request: Request) {
companyName: body.companyName || undefined,
vatId: normalizedVatId,
vatExempt,
+ kleinunternehmer,
deliveryMethod: body.deliveryMethod,
street: body.street,
packstationNumber: body.packstationNumber,
@@ -393,5 +409,6 @@ export async function POST(request: Request) {
discountCode: body.discountCode || null,
discountAmount,
vatExempt,
+ kleinunternehmer,
});
}
diff --git a/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx b/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx
index 3acc46a..ab82171 100644
--- a/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx
+++ b/app/bestellbestaetigung/components/BestellbestaetigungContent.tsx
@@ -33,7 +33,8 @@ function parseOrderSnapshot(raw: string): OrderSnapshot | null {
typeof data.paymentMethodTitle !== "string" ||
(data.discountCode !== null && typeof data.discountCode !== "string") ||
typeof data.discountAmount !== "number" ||
- typeof data.vatExempt !== "boolean"
+ typeof data.vatExempt !== "boolean" ||
+ typeof data.kleinunternehmer !== "boolean"
) {
return null;
}
@@ -229,7 +230,7 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
{entry.variant ? ` (${entry.variant})` : ""}