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:
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ const FALLBACK: CompanySettings = {
|
||||
sellerEmail: "",
|
||||
vatId: "",
|
||||
taxRatePercent: 19,
|
||||
kleinunternehmer: false,
|
||||
iban: null,
|
||||
bic: null,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user