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})` : ""}
- {entry.qty} × {formatPrice(unitPrice)} inkl. {taxRate}% MwSt. + {entry.qty} × {formatPrice(unitPrice)} {!order.kleinunternehmer && inkl. {taxRate}% MwSt.}
@@ -281,7 +282,9 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate: {formatPrice(total)} - {order.vatExempt ? ( + {order.kleinunternehmer ? ( +
Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.
+ ) : order.vatExempt ? (Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)
) : (Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.
+ ) : ( +- {entry.qty} × {formatPrice(unitPrice)} inkl. {taxRate}% MwSt. + {entry.qty} × {formatPrice(unitPrice)} {!kleinunternehmer && inkl. {taxRate}% MwSt.}
{formatPrice(entry.qty * unitPrice)}
@@ -1235,7 +1242,9 @@ export function CheckoutContent({ {formatPrice(displayTotal)} - {vatExemptPreview ? ( + {kleinunternehmer ? ( +Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.
+ ) : vatExemptPreview ? (Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)
) : ({formatPrice(product.price)}
-inkl. {taxRate}% MwSt. zzgl. Versand
+{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
diff --git a/app/konto/bestellungen/[orderNumber]/page.tsx b/app/konto/bestellungen/[orderNumber]/page.tsx index 1e139e1..e2f04e9 100644 --- a/app/konto/bestellungen/[orderNumber]/page.tsx +++ b/app/konto/bestellungen/[orderNumber]/page.tsx @@ -100,7 +100,9 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr {order.vatId && (USt-IdNr. {order.vatId} - {order.vatExempt && " · steuerfreie innergemeinschaftliche Lieferung"} + {order.kleinunternehmer + ? " · Kleinunternehmer gem. § 19 UStG" + : order.vatExempt && " · steuerfreie innergemeinschaftliche Lieferung"}
)} @@ -131,7 +133,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr {item.quantity} × {item.productName} {item.variantName ? ` (${item.variantName})` : ""} -inkl. {item.taxRatePercent}% MwSt.
+ {!order.kleinunternehmer &&inkl. {item.taxRatePercent}% MwSt.
} {item.bundleContents &&{item.bundleContents}
} {item.returnQuantity > 0 && (davon {item.returnQuantity} zurückgesendet
@@ -174,7 +176,11 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr {formatPrice(order.total)} -Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.
+ ) : ( +Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands diff --git a/app/todo-cards/components/Pricing.tsx b/app/todo-cards/components/Pricing.tsx index a6e9d8b..b2d8c4b 100644 --- a/app/todo-cards/components/Pricing.tsx +++ b/app/todo-cards/components/Pricing.tsx @@ -1,7 +1,7 @@ import Image from "next/image"; import { AddToCartButton } from "../../components/AddToCartButton"; import { Reveal } from "../../components/Reveal"; -import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent } from "../../lib/payload"; +import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload"; import { formatPrice, discountPercent } from "../../lib/format"; import { effectiveTaxRate } from "../../lib/cartTotals"; @@ -18,10 +18,11 @@ const bullets = [ // reasoning; the bullet list stays hand-written since it's spec detail, // not something the Products collection models. export async function Pricing() { - const [product, shipping, defaultTaxRate] = await Promise.all([ + const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([ getProductBySlug("todo-karten"), getShippingSettings(), getDefaultTaxRatePercent(), + getKleinunternehmer(), ]); if (!product) return null; const discount = discountPercent(product.price, product.compareAtPrice); @@ -87,7 +88,7 @@ export async function Pricing() { )}
{formatPrice(product.price)}
-inkl. {taxRate}% MwSt. zzgl. Versand
+{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}
Lieferzeit: {shipping.totalDays.min}–{shipping.totalDays.max} Werktage innerhalb Deutschlands
diff --git a/app/todo-cards/components/TodoKartenHero.tsx b/app/todo-cards/components/TodoKartenHero.tsx index 4eaf14a..6093272 100644 --- a/app/todo-cards/components/TodoKartenHero.tsx +++ b/app/todo-cards/components/TodoKartenHero.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import Image from "next/image"; import { AddToCartButton } from "../../components/AddToCartButton"; import { Reveal } from "../../components/Reveal"; -import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent } from "../../lib/payload"; +import { getProductBySlug, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload"; import { formatPrice, discountPercent } from "../../lib/format"; import { effectiveTaxRate } from "../../lib/cartTotals"; @@ -19,10 +19,11 @@ const checklist = [ // §1 Abs.1 Nr.8 EGBGB's delivery-date disclosure needs to sit next to every // buy button, not just one of them. export async function TodoKartenHero() { - const [product, shipping, defaultTaxRate] = await Promise.all([ + const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([ getProductBySlug("todo-karten"), getShippingSettings(), getDefaultTaxRatePercent(), + getKleinunternehmer(), ]); const discount = product ? discountPercent(product.price, product.compareAtPrice) : null; const taxRate = product ? effectiveTaxRate(product, defaultTaxRate) : null; @@ -107,7 +108,7 @@ export async function TodoKartenHero() {{formatPrice(product.compareAtPrice!)}
)}{formatPrice(product.price)}
-inkl. {taxRate}% MwSt.
+ {!kleinunternehmer &&inkl. {taxRate}% MwSt.
} )}diff --git a/package-lock.json b/package-lock.json index 14f7784..f3678e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -358,8 +358,8 @@ } }, "node_modules/@einfach-produktiv/invoicing": { - "version": "0.1.0", - "resolved": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#c28ceb7bd34d6297f677bf3d17aa791525654cd3", + "version": "0.2.1", + "resolved": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#439165c98d6c3cc5c7c7cfe6e0a08d9242983ac8", "dependencies": { "@e-invoice-eu/core": "^3.1.1" },