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)

) : ( diff --git a/app/cart/components/CartContent.tsx b/app/cart/components/CartContent.tsx index a15c646..17e6bfd 100644 --- a/app/cart/components/CartContent.tsx +++ b/app/cart/components/CartContent.tsx @@ -22,6 +22,7 @@ export function CartContent({ freeShippingThreshold, shippingSettings, defaultTaxRate, + kleinunternehmer, showDiscountField, }: { trustBadges: TrustBadge[]; @@ -42,6 +43,11 @@ export function CartContent({ * override taxRatePercent themselves — see lib/cartTotals.ts's * effectiveTaxRate(). */ defaultTaxRate: number; + /** §19 UStG — this tenant's company-settings.kleinunternehmer (Payload's + * lib/payload.ts's getKleinunternehmer(), same ISR freshness as + * defaultTaxRate above). Drops the "inkl. X% MwSt." hints and the VAT + * breakdown in favor of the §19 notice below. */ + kleinunternehmer: boolean; /** Whether Payload currently has at least one active discount code at * all (lib/discountServer.ts's hasActiveDiscountCode()) — no point * showing an open "enter a code" field when nothing could ever validate @@ -236,7 +242,7 @@ export function CartContent({ {formatPrice(product.compareAtPrice!)} )} {formatPrice(unitPrice)} - inkl. {taxRate}% MwSt. + {!kleinunternehmer && inkl. {taxRate}% MwSt.}

@@ -413,7 +419,11 @@ export function CartContent({ {formatPrice(total)} - + {kleinunternehmer ? ( +

Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.

+ ) : ( + + )} {formatPrice(product.compareAtPrice!)}
)} {formatPrice(product.price)} - inkl. {taxRate}% MwSt. + {!kleinunternehmer && inkl. {taxRate}% MwSt.}

{/* Always rendered, text conditional — min-h reserves this line's height in both states so cards in the same row diff --git a/app/cart/page.tsx b/app/cart/page.tsx index 4378af6..1cecbd3 100644 --- a/app/cart/page.tsx +++ b/app/cart/page.tsx @@ -4,7 +4,7 @@ import { CartContent } from "./components/CartContent"; import { RelatedProducts } from "./components/RelatedProducts"; import { TrustRow } from "../components/TrustRow"; import { Footer } from "../components/Footer"; -import { getCartTrustBadges, getShippingMethods, getShippingSettings, getDefaultTaxRatePercent } from "../lib/payload"; +import { getCartTrustBadges, getShippingMethods, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload"; import { hasActiveDiscountCode } from "../lib/discountServer"; // robots: noindex — transactional page (mirrors a specific shopper's cart @@ -20,11 +20,12 @@ export const metadata: Metadata = { }; export default async function CartPage() { - const [trustBadges, shippingMethods, shipping, defaultTaxRate, showDiscountField] = await Promise.all([ + const [trustBadges, shippingMethods, shipping, defaultTaxRate, kleinunternehmer, showDiscountField] = await Promise.all([ getCartTrustBadges(), getShippingMethods(), getShippingSettings(), getDefaultTaxRatePercent(), + getKleinunternehmer(), hasActiveDiscountCode(), ]); @@ -55,10 +56,11 @@ export default async function CartPage() { freeShippingThreshold={freeShippingThreshold} shippingSettings={shipping} defaultTaxRate={defaultTaxRate} + kleinunternehmer={kleinunternehmer} showDiscountField={showDiscountField} /> - +