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
+62
View File
@@ -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`
@@ -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,
@@ -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,
+19 -2
View File
@@ -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,
});
}
@@ -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})` : ""}
</p>
<p className="text-label text-text-muted">
{entry.qty} × {formatPrice(unitPrice)} <span>inkl. {taxRate}% MwSt.</span>
{entry.qty} × {formatPrice(unitPrice)} {!order.kleinunternehmer && <span>inkl. {taxRate}% MwSt.</span>}
</p>
</div>
<p className="text-body-sm text-text-primary whitespace-nowrap">
@@ -281,7 +282,9 @@ export function BestellbestaetigungContent({ defaultTaxRate }: { defaultTaxRate:
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
</div>
{order.vatExempt ? (
{order.kleinunternehmer ? (
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
) : order.vatExempt ? (
<p className="text-label text-text-muted">Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)</p>
) : (
<VatBreakdown groups={taxBreakdown} />
+12 -2
View File
@@ -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({
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-body-sm text-text-primary">{formatPrice(unitPrice)}</span>
<span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
</div>
</div>
@@ -413,7 +419,11 @@ export function CartContent({
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
</div>
<VatBreakdown groups={taxBreakdown} />
{kleinunternehmer ? (
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
) : (
<VatBreakdown groups={taxBreakdown} />
)}
</div>
<Link
+2 -2
View File
@@ -30,7 +30,7 @@ function pickAvailable(allIds: string[], excludeIds: string[], keep: string[], c
return [...keep, ...pickRandom(allIds, [...excludeIds, ...keep], missing)];
}
export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number }) {
export function RelatedProducts({ defaultTaxRate, kleinunternehmer }: { defaultTaxRate: number; kleinunternehmer: boolean }) {
const cart = useCart();
const products = useProducts();
// Cart/checkout resolve any product regardless of `active` (see
@@ -188,7 +188,7 @@ export function RelatedProducts({ defaultTaxRate }: { defaultTaxRate: number })
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
<span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
{/* Always rendered, text conditional min-h reserves this
line's height in both states so cards in the same row
+5 -3
View File
@@ -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}
/>
</Suspense>
<RelatedProducts defaultTaxRate={defaultTaxRate} />
<RelatedProducts defaultTaxRate={defaultTaxRate} kleinunternehmer={kleinunternehmer} />
<TrustRow />
</main>
<Footer />
+12 -3
View File
@@ -97,6 +97,7 @@ export function CheckoutContent({
trustBadges,
shippingSettings,
defaultTaxRate,
kleinunternehmer,
customerEmail,
savedProfile,
}: {
@@ -113,6 +114,11 @@ export function CheckoutContent({
shippingSettings: ShippingSettings;
/** Tenant's default VAT rate, same role as CartContent's own prop. */
defaultTaxRate: number;
/** §19 UStG same role as CartContent's own prop. Also forces the VIES
* exemption preview off below: a Kleinunternehmer never charges VAT on
* anything, domestic or cross-border, so there's nothing left for the
* intra-community exemption to zero-rate. */
kleinunternehmer: boolean;
/** From the checkout page's own session read (app/lib/customerAuth.ts)
* null means no account is logged in yet, which flips "1. Rechnungsadresse"
* into inline-registration mode (password field shown, account created on
@@ -341,7 +347,7 @@ export function CheckoutContent({
// when set, the billing country otherwise — the exemption depends on
// where the goods actually move to, not necessarily the invoice address.
const buyerDestinationCountry = destinationCountry(country, hasDifferentShippingAddress, shippingCountry);
const vatExemptPreview = vatIdViesStatus === "valid" && isExemptionEligibleCountry(buyerDestinationCountry);
const vatExemptPreview = !kleinunternehmer && vatIdViesStatus === "valid" && isExemptionEligibleCountry(buyerDestinationCountry);
const exemptTotalsPreview = vatExemptPreview
? computeExemptTotals(
items.map(({ entry, product }) => ({
@@ -538,6 +544,7 @@ export function CheckoutContent({
discountCode: data.discountCode,
discountAmount: data.discountAmount,
vatExempt: Boolean(data.vatExempt),
kleinunternehmer: Boolean(data.kleinunternehmer),
};
try {
window.sessionStorage.setItem(ORDER_KEY, JSON.stringify(snapshot));
@@ -1160,7 +1167,7 @@ export function CheckoutContent({
{entry.variant ? ` (${entry.variant})` : ""}
</p>
<p className="text-label text-text-muted">
{entry.qty} × {formatPrice(unitPrice)} <span>inkl. {taxRate}% MwSt.</span>
{entry.qty} × {formatPrice(unitPrice)} {!kleinunternehmer && <span>inkl. {taxRate}% MwSt.</span>}
</p>
</div>
<p className="text-body-sm text-text-primary whitespace-nowrap">{formatPrice(entry.qty * unitPrice)}</p>
@@ -1235,7 +1242,9 @@ export function CheckoutContent({
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(displayTotal)}</span>
</div>
{vatExemptPreview ? (
{kleinunternehmer ? (
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
) : vatExemptPreview ? (
<p className="text-label text-text-muted">Steuerfreie innergemeinschaftliche Lieferung (§4 Nr. 1b UStG)</p>
) : (
<VatBreakdown groups={taxBreakdown} />
+4 -2
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { CheckoutContent } from "./components/CheckoutContent";
import { TrustRow } from "../components/TrustRow";
import { Footer } from "../components/Footer";
import { getShippingMethods, getShippingCountries, getPaymentMethods, getCartTrustBadges, getShippingSettings, getDefaultTaxRatePercent } from "../lib/payload";
import { getShippingMethods, getShippingCountries, getPaymentMethods, getCartTrustBadges, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
import { getSessionCustomer, getCustomerProfile } from "../lib/customerAuth";
// robots: noindex — transactional page, same reasoning as /cart.
@@ -16,13 +16,14 @@ export const metadata: Metadata = {
};
export default async function CheckoutPage() {
const [shippingMethods, shippingCountries, paymentMethods, trustBadges, shippingSettings, defaultTaxRate, session] = await Promise.all([
const [shippingMethods, shippingCountries, paymentMethods, trustBadges, shippingSettings, defaultTaxRate, kleinunternehmer, session] = await Promise.all([
getShippingMethods(),
getShippingCountries(),
getPaymentMethods(),
getCartTrustBadges(),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
getSessionCustomer(),
]);
// Full profile (incl. saved address) only fetched when a session exists
@@ -39,6 +40,7 @@ export default async function CheckoutPage() {
trustBadges={trustBadges}
shippingSettings={shippingSettings}
defaultTaxRate={defaultTaxRate}
kleinunternehmer={kleinunternehmer}
customerEmail={session?.customer.email ?? null}
savedProfile={profile}
/>
@@ -31,7 +31,12 @@ export function LiveCompanySettingsPreviewClient({ initialSettings }: { initialS
return (
<PDFViewer style={{ width: "100%", height: "100vh", border: "none" }}>
<InvoiceDocument order={SAMPLE_INVOICE_ORDER} seller={data} />
{/* kleinunternehmer isn't part of InvoiceSeller (it's snapshotted
per-order, not read live off the seller see invoicePdf.tsx's
own comment) merged onto the sample order here only, so an
admin toggling the checkbox sees the §19 notice reflected live
without this preview needing its own separate mechanism. */}
<InvoiceDocument order={{ ...SAMPLE_INVOICE_ORDER, kleinunternehmer: data.kleinunternehmer }} seller={data} />
</PDFViewer>
);
}
+1
View File
@@ -23,6 +23,7 @@ const FALLBACK: CompanySettings = {
sellerEmail: "",
vatId: "",
taxRatePercent: 19,
kleinunternehmer: false,
iban: null,
bic: null,
};
+4 -3
View File
@@ -2,7 +2,7 @@ import Image from "next/image";
import Link from "next/link";
import { AddToCartButton } from "./AddToCartButton";
import { Reveal } from "./Reveal";
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent } from "../lib/payload";
import { getSpotlightProduct, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../lib/payload";
import { formatPrice, discountPercent } from "../lib/format";
import { effectiveTaxRate } from "../lib/cartTotals";
@@ -23,10 +23,11 @@ import { effectiveTaxRate } from "../lib/cartTotals";
* see Products.ts), not duplicated here as hardcoded literals.
*/
export async function ProductSpotlight() {
const [product, shipping, defaultTaxRate] = await Promise.all([
const [product, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getSpotlightProduct(),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
if (!product) return null;
@@ -85,7 +86,7 @@ export async function ProductSpotlight() {
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
</div>
<p className="text-label text-text-muted">inkl. {taxRate}% MwSt. zzgl. Versand</p>
<p className="text-label text-text-muted">{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}</p>
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
@@ -100,7 +100,9 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
{order.vatId && (
<p className="text-body-sm text-text-muted">
USt-IdNr. {order.vatId}
{order.vatExempt && " · steuerfreie innergemeinschaftliche Lieferung"}
{order.kleinunternehmer
? " · Kleinunternehmer gem. § 19 UStG"
: order.vatExempt && " · steuerfreie innergemeinschaftliche Lieferung"}
</p>
)}
</div>
@@ -131,7 +133,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
{item.quantity} × {item.productName}
{item.variantName ? ` (${item.variantName})` : ""}
</p>
<p className="text-label text-text-muted">inkl. {item.taxRatePercent}% MwSt.</p>
{!order.kleinunternehmer && <p className="text-label text-text-muted">inkl. {item.taxRatePercent}% MwSt.</p>}
{item.bundleContents && <p className="text-label text-text-muted">{item.bundleContents}</p>}
{item.returnQuantity > 0 && (
<p className="text-label text-text-muted">davon {item.returnQuantity} zurückgesendet</p>
@@ -174,7 +176,11 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
<span className="flex-1" />
<span className="font-bold text-h-small text-text-primary">{formatPrice(order.total)}</span>
</div>
<VatBreakdown groups={taxBreakdown} />
{order.kleinunternehmer ? (
<p className="text-label text-text-muted">Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</p>
) : (
<VatBreakdown groups={taxBreakdown} />
)}
</div>
</div>
+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;
}
+8 -3
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import Image from "next/image";
import { getProducts, getShippingSettings, getDefaultTaxRatePercent } from "../../lib/payload";
import { getProducts, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { effectiveTaxRate } from "../../lib/cartTotals";
import { formatPrice, discountPercent } from "../../lib/format";
import { RevealGroup, RevealItem } from "../../components/Reveal";
@@ -13,7 +13,12 @@ import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
// gives faster first paint and no loading flash.
export async function ProductGrid() {
const [allProducts, shipping, defaultTaxRate] = await Promise.all([getProducts(), getShippingSettings(), getDefaultTaxRatePercent()]);
const [allProducts, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
getProducts(),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
]);
const products = allProducts.filter((p) => p.active);
if (products.length === 0) {
@@ -78,7 +83,7 @@ export async function ProductGrid() {
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
<span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
+4 -3
View File
@@ -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() {
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
</div>
<p className="text-label text-text-muted">inkl. {taxRate}% MwSt. zzgl. Versand</p>
<p className="text-label text-text-muted">{kleinunternehmer ? "zzgl. Versand" : `inkl. ${taxRate}% MwSt. zzgl. Versand`}</p>
<p className="text-label text-text-muted">
Lieferzeit: {shipping.totalDays.min}{shipping.totalDays.max} Werktage innerhalb Deutschlands
</p>
+4 -3
View File
@@ -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() {
<p className="text-body text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</p>
)}
<p className="font-bold text-h3 text-text-primary">{formatPrice(product.price)}</p>
<p className="text-label text-text-muted">inkl. {taxRate}% MwSt.</p>
{!kleinunternehmer && <p className="text-label text-text-muted">inkl. {taxRate}% MwSt.</p>}
</div>
)}
<p className="text-label text-text-muted">
+2 -2
View File
@@ -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"
},