Compare commits

...
2 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5 1bdf4df04a feat: Gebiete mit Zollpflicht vom Versand ausgenommen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m7s
Vorgabe war: nur dorthin liefern, wo keine Zollabwicklung noetig ist. Die
EU-Einstellung erfuellt das auf Laenderebene — die Union ist eine
Zollunion, eine Sendung von Deutschland nach Frankreich braucht keine
Zollanmeldung. Der Laendercode allein genuegt aber nicht.

Einige Gebiete gehoeren zu einem EU-Mitgliedstaat, liegen aber ausserhalb
des Zoll- und/oder Steuergebiets der Union. Dorthin ist jede Sendung eine
Ausfuhr mit Zollinhaltserklaerung, Handelsrechnung und moeglichen
Einfuhrabgaben beim Empfaenger. Zwei davon liegen in DEUTSCHLAND:

  Helgoland (27498) und Büsingen am Hochrhein (78266)

Sie waren also selbst bei der Einstellung "Nur Deutschland" betroffen.
Dazu kommen Kanarische Inseln, Ceuta, Melilla, die franzoesischen
Ueberseegebiete, Livigno, Campione d'Italia, Åland und der Berg Athos.

Erkennbar sind sie nur an der Postleitzahl. findCustomsExcludedArea
prueft sie je Land, denn dieselbe Ziffernfolge bedeutet anderswo etwas
anderes: "22" ist in Finnland Åland, in Italien die Provinz Como. Die
Checkout-Route weist solche Anschriften mit einer Meldung ab, die das
Gebiet benennt und auf die Abholung verweist.

Azoren und Madeira sind bewusst NICHT ausgenommen: sie gehoeren voll zum
Zoll- und Steuergebiet, dorthin gilt der normale Binnenversand. Ein Test
haelt das fest, damit sie nicht versehentlich mitgesperrt werden.

Die Hinweistexte auf "Versand und Zahlung" nennen die Ausnahmen jetzt
ausdruecklich — in beiden Einstellungen und beiden Sprachen.

8 neue Tests in shipping-region.test.ts, 1 in der Checkout-Route
("weist Helgoland ab, obwohl es in Deutschland liegt").

tsc: 0 Fehler. vitest: 359/359 gruen (zuvor 350). eslint: 0 Warnungen.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 08:03:10 +02:00
Nick SzaunigandClaude Opus 5 6d7a77c8dd feat: Liefergebiet im Admin-Bereich umschaltbar (Deutschland / EU)
Admin-Bereich → Inhalte → Firmendaten → "Liefergebiet". Auswahl zwischen
"Nur Deutschland" und "Europäische Union". Der Wert steuert drei Stellen
gleichzeitig, damit sie nicht auseinanderlaufen koennen:

1. Checkout-Formular: bei "EU" eine Laenderauswahl mit den 27
   Mitgliedstaaten, bei "Nur Deutschland" stattdessen ein Satz, der das
   Gebiet benennt — bevor jemand die Adresse eintippt und erst beim
   Abschicken erfaehrt, dass daraus nichts wird. Eine Auswahl mit einem
   einzigen Eintrag waere eine Attrappe.

2. api/checkout/route.ts: verbindliche Pruefung. Die Auswahl im Formular
   ist Bequemlichkeit, kein Schutz — der Endpunkt nimmt JSON entgegen und
   laesst sich ohne Formular aufrufen. Ohne diese Pruefung entstuende eine
   Bestellung mit einer Lieferadresse, fuer die weder Versandweg noch
   Umsatzsteuerpflicht geklaert sind, und bezahlt waere sie dann auch
   schon. Abgewiesen wird mit einer Meldung, die sagt, was stattdessen
   geht (Adresse im Gebiet oder Abholung).

3. Seite "Versand und Zahlung": eigener Abschnitt "Liefergebiet" mit dem
   passenden Satz. Eigener Abschnitt statt Nebensatz, weil die Frage vor
   dem Warenkorb entscheidet, ob ein Kauf ueberhaupt zustande kommt.

Die Vorgabe ist "Nur Deutschland", und zwar an jeder Stelle einzeln: ein
fehlender oder unsinniger Wert, eine nicht durchgereichte Prop — im
Zweifel liefert der Shop in WENIGER Laender, nie in mehr.

"EU" heisst die 27 Mitgliedstaaten, nicht Europa im geografischen Sinn.
Schweiz, Norwegen und das Vereinigte Koenigreich sind Zollausland; dorthin
zu liefern braucht mehr als einen Haken im Admin-Bereich.

Technisch dazugekommen:
  - lib/shipping-region.ts, reine Logik ohne Datenbankzugriff (wie
    shipping-calc.ts), 11 Tests.
  - content-registry.ts kennt jetzt den Feldtyp "select" mit "options" —
    ein Freitextfeld waere hier falsch: ein Tippfehler im Liefergebiet
    wuerde stillschweigend auf den Vorgabewert zurueckfallen, und niemand
    saehe, dass die Umstellung nicht gegriffen hat.
  - Laendernamen ueber Intl.DisplayNames statt 27 Namen je Sprache im
    Woerterbuch: eine eigene Liste waere ab dem naechsten Beitritt falsch.
  - 3 weitere Tests in der Checkout-Route (Abweisung, EU-Mitglied durch,
    Drittland trotzdem abgewiesen).

tsc: 0 Fehler. vitest: 350/350 gruen (zuvor 336). eslint: 0 Warnungen.
"next build" laeuft durch.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 07:59:15 +02:00
13 changed files with 543 additions and 7 deletions

No files matched your search

+25
View File
@@ -12,6 +12,9 @@ import type { Locale } from "@/i18n/config";
const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
text: 1,
textarea: 3,
// Auswahlfelder rendern ein <select> und nutzen ROWS_BY_TYPE nicht — Wert
// nur vorhanden, damit dieses Record vollstaendig bleibt.
select: 0,
"list-simple": 4,
"list-titled": 5,
"list-numbered": 6,
@@ -57,6 +60,28 @@ function FieldEditor({
if (def.type === "video") {
return <AdminVideoField name={def.key} label={label} initialValue={row?.valueDe ?? ""} />;
}
if (def.type === "select") {
// Ohne gepflegten Wert ist der Vorgabewert vorausgewaehlt — genau der
// gilt zur Laufzeit ohnehin, die Anzeige soll das nicht anders
// darstellen als die Wirklichkeit.
const current = row?.valueDe?.trim() ? row.valueDe : def.getDefault("de");
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<select
name={def.key}
defaultValue={current}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
>
{(def.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{locale === "de" ? option.labelDe : option.labelEn}
</option>
))}
</select>
</label>
);
}
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
+50 -1
View File
@@ -44,7 +44,11 @@ vi.mock("@/lib/rate-limit", () => ({
getClientIp: vi.fn(async () => "203.0.113.7"),
}));
vi.mock("@/lib/shipping", () => ({ getShippingCostCents: vi.fn(async () => 700) }));
const getShippingRegion = vi.fn(async () => "DE" as const);
vi.mock("@/lib/shipping", () => ({
getShippingCostCents: vi.fn(async () => 700),
getShippingRegion: () => getShippingRegion(),
}));
vi.mock("@/lib/bulk-discount", () => ({ ensureBulkDiscountCode: vi.fn(async () => undefined) }));
vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" }));
vi.mock("@/i18n/locale", () => ({ getLocale: vi.fn(async () => "de") }));
@@ -108,6 +112,7 @@ beforeEach(() => {
rateLimit.mockReturnValue(true);
verifyTurnstileToken.mockResolvedValue(true);
getProductsByIds.mockResolvedValue([honey]);
getShippingRegion.mockResolvedValue("DE");
db.discountCode.findMany.mockResolvedValue([]);
db.inventory.updateMany.mockResolvedValue({ count: 1 });
db.order.create.mockResolvedValue(ORDER);
@@ -177,6 +182,50 @@ describe("Checkout-Route", () => {
expect(db.order.create).not.toHaveBeenCalled();
});
test("weist eine Lieferadresse ausserhalb des Liefergebiets ab", async () => {
// Die Laenderauswahl im Formular bietet nur zulaessige Laender an — das
// ist Bequemlichkeit, kein Schutz: dieser Endpunkt nimmt JSON entgegen
// und laesst sich ohne Formular aufrufen.
const response = await post({ ...validPayload, country: "AT" });
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringContaining("innerhalb Deutschlands"),
});
expect(db.order.create).not.toHaveBeenCalled();
});
test('laesst bei Liefergebiet "EU" ein anderes Mitgliedsland durch', async () => {
getShippingRegion.mockResolvedValue("EU" as never);
const response = await post({ ...validPayload, country: "AT" });
expect(response.status).toBe(200);
});
test('weist bei Liefergebiet "EU" ein Drittland trotzdem ab', async () => {
// Die Schweiz liegt in Europa, ist aber Zollausland.
getShippingRegion.mockResolvedValue("EU" as never);
const response = await post({ ...validPayload, country: "CH" });
expect(response.status).toBe(400);
expect(db.order.create).not.toHaveBeenCalled();
});
test("weist Helgoland ab, obwohl es in Deutschland liegt", async () => {
// Helgoland liegt ausserhalb des EU-Zollgebiets: dorthin ist jede
// Sendung eine Ausfuhr mit Zollinhaltserklaerung. Der Laendercode allein
// wuerde das durchgehen lassen.
const response = await post({ ...validPayload, zip: "27498" });
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringContaining("Helgoland"),
});
expect(db.order.create).not.toHaveBeenCalled();
});
test("bricht ab, wenn der Bestand waehrend des Checkouts nicht mehr reicht", async () => {
// Das bedingte updateMany trifft keine Zeile mehr — jemand anders war
// schneller.
+38 -1
View File
@@ -9,7 +9,8 @@ import { getCurrentCustomer } from "@/lib/auth";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { isTrustedOrigin } from "@/lib/csrf";
import { verifyTurnstileToken } from "@/lib/turnstile";
import { getShippingCostCents } from "@/lib/shipping";
import { getShippingCostCents, getShippingRegion } from "@/lib/shipping";
import { findCustomsExcludedArea, isCountryAllowed } from "@/lib/shipping-region";
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
import { releaseUnpaidOrder } from "@/lib/order-release";
@@ -177,6 +178,42 @@ export async function POST(request: Request) {
{ status: 400 }
);
}
// Verbindliche Pruefung des Liefergebiets. Die Laenderauswahl im Formular
// bietet ohnehin nur zulaessige Laender an — das ist Bequemlichkeit, kein
// Schutz: dieser Endpunkt nimmt JSON entgegen und laesst sich ohne
// Formular aufrufen. Ohne die Pruefung hier entstuende eine Bestellung
// mit einer Lieferadresse, fuer die weder Versandweg noch
// Umsatzsteuerpflicht geklaert sind — und bezahlt waere sie dann auch
// schon.
const region = await getShippingRegion();
if (!isCountryAllowed(region, input.country)) {
return NextResponse.json(
{
error:
region === "DE"
? "Wir versenden derzeit ausschließlich innerhalb Deutschlands. Bitte gib eine Lieferadresse in Deutschland an oder wähle die Abholung."
: "Wir versenden derzeit ausschließlich innerhalb der Europäischen Union. Bitte gib eine Lieferadresse in der EU an oder wähle die Abholung.",
},
{ status: 400 }
);
}
// Der Laendercode allein genuegt nicht: einige Gebiete gehoeren zu einem
// EU-Mitgliedstaat, liegen aber ausserhalb des Zoll- und/oder
// Steuergebiets der Union. Dorthin ist jede Sendung eine Ausfuhr mit
// Zollinhaltserklaerung — zwei davon liegen in Deutschland (Helgoland und
// Buesingen am Hochrhein), sind also selbst bei "Nur Deutschland"
// betroffen. Erkennbar sind sie nur an der Postleitzahl.
const excludedArea = findCustomsExcludedArea(input.country, input.zip);
if (excludedArea) {
return NextResponse.json(
{
error: `Nach ${excludedArea} können wir leider nicht versenden: dorthin ist jede Sendung zollpflichtig. Bitte gib eine andere Lieferadresse an oder wähle die Abholung.`,
},
{ status: 400 }
);
}
}
const productIds = input.items.map((item) => item.productId);
+56 -1
View File
@@ -10,9 +10,28 @@ import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-
import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
import { useI18n } from "@/i18n/i18n-provider";
import { TurnstileWidget } from "@/components/turnstile-widget";
import { allowedCountryCodes, type ShippingRegion } from "@/lib/shipping-region";
type Fulfillment = "VERSAND" | "ABHOLUNG";
/**
* Laendername in der Sprache der Oberflaeche.
*
* Intl.DisplayNames statt 27 Laendernamen je Sprache im Woerterbuch: die
* Namen stehen bereits in jedem Browser und jeder Node-Laufzeit, und eine
* eigene Liste waere ab dem naechsten Beitritt oder der naechsten Umbenennung
* falsch. Faellt die Aufloesung aus, bleibt der Code stehen — unschoen, aber
* eindeutig, und die Bestellung geht trotzdem.
*/
function countryName(code: string, locale: string): string {
try {
return new Intl.DisplayNames([locale], { type: "region" }).of(code) ?? code;
} catch {
return code;
}
}
/** Hausanschrift oder DHL-Packstation — nur bei fulfillment === "VERSAND" relevant. */
type DeliveryTarget = "ADRESSE" | "PACKSTATION";
@@ -42,6 +61,7 @@ export function CheckoutForm({
bulkDiscountTiers,
packstationSearchAvailable = false,
turnstileSiteKey,
shippingRegion = "DE",
}: {
initialValues?: CheckoutInitialValues;
tiers: ShippingTierLike[];
@@ -54,6 +74,13 @@ export function CheckoutForm({
packstationSearchAvailable?: boolean;
/** Leer, wenn Turnstile nicht konfiguriert ist — dann entfaellt das Widget. */
turnstileSiteKey?: string;
/**
* Wohin geliefert wird (Admin-Bereich → Inhalte → Liefergebiet). Steuert
* die Laenderauswahl unten. Vorgabe ist der engste Zuschnitt: waere sie
* "EU", wuerde ein vergessenes Durchreichen der Prop stillschweigend mehr
* Laender anbieten, als der Shop beliefert.
*/
shippingRegion?: ShippingRegion;
}) {
const { t, locale } = useI18n();
const items = useCartStore((s) => s.items);
@@ -64,6 +91,9 @@ export function CheckoutForm({
// Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit
// Token-Rueckgabe statt eines versteckten Feldes.
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const countryCodes = allowedCountryCodes(shippingRegion);
// Nur eine Auswahl anbieten, wenn es etwas zu waehlen gibt.
const [country, setCountry] = useState(countryCodes[0]);
const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -157,7 +187,7 @@ export function CheckoutForm({
city: usesPackstation
? selectedPackstation?.city
: String(formData.get("city") ?? "") || undefined,
country: "DE",
country,
packstationNumber: usesPackstation ? selectedPackstation?.number : undefined,
postNumber: usesPackstation
? String(formData.get("postNumber") ?? "").trim() || undefined
@@ -327,6 +357,31 @@ export function CheckoutForm({
required
/>
</div>
{/* Bei nur einem Lieferland waere eine Auswahl mit einem
Eintrag eine Attrappe — dann steht dort ein Satz, der das
Gebiet benennt, bevor jemand die Adresse eintippt und erst
beim Abschicken erfaehrt, dass daraus nichts wird. */}
{countryCodes.length > 1 ? (
<label className="block text-sm">
<span className="text-ink/70">{t.checkoutPage.countryLabel}</span>
<select
name="country"
value={country}
onChange={(event) => setCountry(event.target.value)}
autoComplete="country"
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
>
{countryCodes.map((code) => (
<option key={code} value={code}>
{countryName(code, locale)}
</option>
))}
</select>
</label>
) : (
<p className="text-xs text-ink/65">{t.checkoutPage.shippingRegionDeNote}</p>
)}
</div>
) : null}
+8 -2
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { getCurrentCustomer } from "@/lib/auth";
import { getAddressesForCustomer } from "@/lib/account-data";
import { getShippingTiers } from "@/lib/shipping";
import { getShippingTiers, getShippingRegion } from "@/lib/shipping";
import { getBulkDiscountTiers } from "@/lib/bulk-discount";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
@@ -20,7 +20,11 @@ export async function generateMetadata(): Promise<Metadata> {
}
export default async function CheckoutPage() {
const [tiers, bulkDiscountTiers] = await Promise.all([getShippingTiers(), getBulkDiscountTiers()]);
const [tiers, bulkDiscountTiers, shippingRegion] = await Promise.all([
getShippingTiers(),
getBulkDiscountTiers(),
getShippingRegion(),
]);
const tierValues = tiers.map((t) => ({
minQuantity: t.minQuantity,
maxQuantity: t.maxQuantity,
@@ -33,6 +37,7 @@ export default async function CheckoutPage() {
tiers={tierValues}
bulkDiscountTiers={bulkDiscountTiers}
packstationSearchAvailable={isPackstationSearchConfigured()}
shippingRegion={shippingRegion}
/>;
// getAddressesForCustomer sortiert Standardadresse (isDefault) zuerst, sonst
@@ -57,5 +62,6 @@ export default async function CheckoutPage() {
tiers={tierValues}
bulkDiscountTiers={bulkDiscountTiers}
packstationSearchAvailable={isPackstationSearchConfigured()}
shippingRegion={shippingRegion}
/>;
}
+15 -2
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import { LegalPage } from "@/components/legal-page";
import { getShippingTiers } from "@/lib/shipping";
import { getShippingTiers, getShippingRegion } from "@/lib/shipping";
import { formatPrice } from "@/lib/format";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
@@ -14,7 +14,7 @@ export const dynamic = "force-dynamic";
export default async function VersandZahlungPage() {
const locale = await getLocale();
const t = getDictionary(locale);
const tiers = await getShippingTiers();
const [tiers, region] = await Promise.all([getShippingTiers(), getShippingRegion()]);
const tierParagraphs =
tiers.length > 0
@@ -41,6 +41,19 @@ export default async function VersandZahlungPage() {
heading: t.versandZahlungPage.shippingHeading,
paragraphs: [t.versandZahlungPage.shippingIntro, ...tierParagraphs, t.versandZahlungPage.pickupNote],
},
{
// Eigener Abschnitt statt eines Nebensatzes im Versandtext: wohin
// geliefert wird, entscheidet vor dem Warenkorb, ob ein Kauf
// ueberhaupt zustande kommen kann. Welcher Satz hier steht, steuert
// der Admin-Bereich (Inhalte → Firmendaten → Liefergebiet) — und
// derselbe Wert weist im Checkout Bestellungen ausserhalb des
// Gebiets ab, die Seite kann also nicht etwas anderes behaupten als
// der Shop tut.
heading: t.versandZahlungPage.regionHeading,
paragraphs: [
region === "EU" ? t.versandZahlungPage.regionTextEu : t.versandZahlungPage.regionTextDe,
],
},
{
heading: t.versandZahlungPage.paymentHeading,
paragraphs: [t.versandZahlungPage.paymentText],
+5
View File
@@ -391,6 +391,9 @@ export const de: Dictionary = {
tierRange: (min, max, price) => `${min}–${max} Gläser/Artikel: ${price}`,
tierMinimum: (min, price) => `Ab ${min} Gläsern/Artikeln: ${price}`,
pickupNote: "Alternativ kannst du deine Bestellung nach Absprache direkt bei uns in Altenmünster abholen. Dabei fallen keine Versandkosten an.",
regionHeading: "Liefergebiet",
regionTextDe: "Versand ist nur innerhalb Deutschlands möglich. Bestellungen mit einer Lieferadresse außerhalb Deutschlands können wir derzeit leider nicht annehmen. Ausgenommen sind außerdem Helgoland und Büsingen am Hochrhein: beide liegen außerhalb des EU-Zollgebiets, dorthin wäre jede Sendung zollpflichtig.",
regionTextEu: "Versand ist in die gesamte Europäische Union möglich — dorthin fällt kein Zoll an. Bestellungen mit einer Lieferadresse außerhalb der EU können wir derzeit leider nicht annehmen. Das gilt auch für Gebiete, die zwar zu einem EU-Land gehören, aber außerhalb des Zollgebiets liegen: Helgoland, Büsingen am Hochrhein, die Kanarischen Inseln, Ceuta und Melilla, die französischen Überseegebiete, Livigno, Campione d’Italia, Åland und den Berg Athos.",
paymentHeading: "Zahlungsmethoden",
paymentText: "Wir bieten Kreditkarte, SEPA-Lastschrift, Klarna, PayPal, Apple Pay und Google Pay über unseren Zahlungsdienstleister Stripe an. Eine Zahlung per Überweisung ist auf Anfrage möglich.",
deliveryTimeHeading: "Lieferzeit",
@@ -1380,6 +1383,8 @@ export const de: Dictionary = {
streetLabel: "Straße und Hausnummer",
zipLabel: "PLZ",
cityLabel: "Ort",
countryLabel: "Land",
shippingRegionDeNote: "Wir versenden derzeit ausschließlich innerhalb Deutschlands.",
discountCodeLabel: "Gutscheincode (optional)",
discountCodePlaceholder: "z.B. WABEN10",
isGiftLabel: "Das ist ein Geschenk",
+5
View File
@@ -391,6 +391,9 @@ export const en: Dictionary = {
tierRange: (min, max, price) => `${min}–${max} jars/items: ${price}`,
tierMinimum: (min, price) => `From ${min} jars/items: ${price}`,
pickupNote: "Alternatively, you can pick up your order directly from us in Altenmünster by arrangement; no shipping costs apply in that case.",
regionHeading: "Shipping region",
regionTextDe: "We ship within Germany only. Unfortunately we cannot currently accept orders with a delivery address outside Germany. Heligoland and Büsingen am Hochrhein are excluded as well: both lie outside the EU customs territory, so any shipment there would be subject to customs.",
regionTextEu: "We ship to the entire European Union — no customs apply there. Unfortunately we cannot currently accept orders with a delivery address outside the EU. The same applies to territories that belong to an EU country but lie outside the customs territory: Heligoland, Büsingen am Hochrhein, the Canary Islands, Ceuta and Melilla, the French overseas territories, Livigno, Campione d’Italia, Åland and Mount Athos.",
paymentHeading: "Payment Methods",
paymentText: "We offer credit card, SEPA direct debit, Klarna, PayPal, Apple Pay, and Google Pay via our payment provider Stripe. Payment by bank transfer is available on request.",
deliveryTimeHeading: "Delivery Time",
@@ -1379,6 +1382,8 @@ export const en: Dictionary = {
streetLabel: "Street and house number",
zipLabel: "Postal code",
cityLabel: "City",
countryLabel: "Country",
shippingRegionDeNote: "We currently ship within Germany only.",
discountCodeLabel: "Discount code (optional)",
discountCodePlaceholder: "e.g. WABEN10",
isGiftLabel: "This is a gift",
+7
View File
@@ -226,6 +226,10 @@ export interface Dictionary {
tierRange: (min: number, max: number, price: string) => string;
tierMinimum: (min: number, price: string) => string;
pickupNote: string;
/** Liefergebiet — welcher Satz erscheint, steuert der Admin-Bereich. */
regionHeading: string;
regionTextDe: string;
regionTextEu: string;
paymentHeading: string;
paymentText: string;
deliveryTimeHeading: string;
@@ -1057,6 +1061,9 @@ export interface Dictionary {
streetLabel: string;
zipLabel: string;
cityLabel: string;
countryLabel: string;
/** Hinweis neben der Laenderauswahl, wenn nur nach Deutschland geliefert wird. */
shippingRegionDeNote: string;
discountCodeLabel: string;
discountCodePlaceholder: string;
isGiftLabel: string;
+29
View File
@@ -1,11 +1,14 @@
import { getDictionary } from "@/i18n/get-dictionary";
import { siteConfig } from "@/lib/site-config";
import { DEFAULT_SHIPPING_REGION } from "@/lib/shipping-region";
import type { Locale } from "@/i18n/config";
import type { TitledText, LegalSectionContent } from "@/i18n/dictionary-type";
export type ContentFieldType =
| "text"
| "textarea"
/** Feste Auswahl statt freiem Text — siehe "options" an ContentFieldDef. */
| "select"
| "list-simple"
| "list-titled"
| "list-numbered"
@@ -23,6 +26,13 @@ export interface ContentFieldDef {
localized: boolean;
/** Liefert den aktuellen Fallback-Text im selben Format, das der Parser fuer diesen Typ erwartet. */
getDefault: (locale: Locale) => string;
/**
* Nur bei type: "select" — die waehlbaren Werte. Ein freies Textfeld waere
* hier falsch: ein Tippfehler im Liefergebiet wuerde stillschweigend auf
* den Vorgabewert zurueckfallen, und niemand saehe, dass die Umstellung
* nicht gegriffen hat.
*/
options?: { value: string; labelDe: string; labelEn: string }[];
}
function joinSimpleList(items: string[]): string {
@@ -56,6 +66,25 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [
{ key: "company.zip", group: "company", labelDe: "PLZ", labelEn: "ZIP", type: "text", localized: false, getDefault: () => siteConfig.address.zip },
{ key: "company.city", group: "company", labelDe: "Ort", labelEn: "City", type: "text", localized: false, getDefault: () => siteConfig.address.city },
// --- Liefergebiet ---
// Steuert dreierlei zugleich: die Laenderauswahl im Checkout, die
// verbindliche Pruefung beim Anlegen der Bestellung (api/checkout/route.ts)
// und den Hinweis auf der Seite "Versand und Zahlung". Bewusst eine
// Auswahl und kein Freitext — siehe "options" oben.
{
key: "shipping.region",
group: "company",
labelDe: "Liefergebiet",
labelEn: "Shipping region",
type: "select",
localized: false,
getDefault: () => DEFAULT_SHIPPING_REGION,
options: [
{ value: "DE", labelDe: "Nur Deutschland", labelEn: "Germany only" },
{ value: "EU", labelDe: "Europäische Union", labelEn: "European Union" },
],
},
// --- Homepage (lokalisiert) ---
{ key: "home.hero.intro", group: "home", labelDe: "Hero: Einleitungstext", labelEn: "Hero: Intro text", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.hero.intro },
{ key: "home.hero.footnote", group: "home", labelDe: "Hero: Fußnote", labelEn: "Hero: Footnote", type: "text", localized: true, getDefault: (l) => getDictionary(l).home.hero.footnote },
+143
View File
@@ -0,0 +1,143 @@
/**
* Tests fuer das Liefergebiet.
*
* Die Frage "darf in dieses Land geliefert werden" entscheidet, ob eine
* Bestellung zustande kommt. Ein Fehler in die eine Richtung sperrt zahlende
* Kundschaft aus, einer in die andere erzeugt Lieferungen in Laender, fuer
* die weder Steuer noch Zoll geklaert sind.
*/
import { describe, test, expect } from "vitest";
import {
DEFAULT_SHIPPING_REGION,
EU_COUNTRY_CODES,
allowedCountryCodes,
findCustomsExcludedArea,
isCountryAllowed,
parseShippingRegion,
} from "./shipping-region";
describe("parseShippingRegion", () => {
test("erkennt die beiden gueltigen Werte", () => {
expect(parseShippingRegion("DE")).toBe("DE");
expect(parseShippingRegion("EU")).toBe("EU");
});
test("ist unempfindlich gegen Schreibweise und Leerzeichen", () => {
expect(parseShippingRegion(" eu ")).toBe("EU");
expect(parseShippingRegion("de")).toBe("DE");
});
test("faellt bei fehlendem oder unsinnigem Wert auf den engsten Zuschnitt zurueck", () => {
// Wichtige Richtung: im Zweifel WENIGER Laender, nicht mehr. Ein Shop
// liefert nie versehentlich weiter, als er wollte.
expect(parseShippingRegion(null)).toBe("DE");
expect(parseShippingRegion("")).toBe("DE");
expect(parseShippingRegion("WELTWEIT")).toBe("DE");
expect(DEFAULT_SHIPPING_REGION).toBe("DE");
});
});
describe("allowedCountryCodes", () => {
test('"DE" erlaubt genau ein Land', () => {
expect(allowedCountryCodes("DE")).toEqual(["DE"]);
});
test('"EU" erlaubt die 27 Mitgliedstaaten, sortiert und ohne Dubletten', () => {
const codes = allowedCountryCodes("EU");
expect(codes).toHaveLength(27);
expect(codes).toEqual([...codes].sort());
expect(new Set(codes).size).toBe(27);
});
test("Drittlaender in Europa sind NICHT enthalten", () => {
// Schweiz, Norwegen und das Vereinigte Koenigreich liegen in Europa, sind
// aber Zollausland — dorthin zu liefern braucht mehr als einen Haken im
// Admin-Bereich.
const codes = allowedCountryCodes("EU");
expect(codes).not.toContain("CH");
expect(codes).not.toContain("NO");
expect(codes).not.toContain("GB");
});
test("Griechenland steht als ISO-Code GR, nicht als EL", () => {
expect(EU_COUNTRY_CODES as readonly string[]).toContain("GR");
expect(EU_COUNTRY_CODES as readonly string[]).not.toContain("EL");
});
});
describe("isCountryAllowed", () => {
test('bei "DE" kommt nur Deutschland durch', () => {
expect(isCountryAllowed("DE", "DE")).toBe(true);
expect(isCountryAllowed("DE", "AT")).toBe(false);
expect(isCountryAllowed("DE", "FR")).toBe(false);
});
test('bei "EU" kommen Mitgliedstaaten durch, Drittlaender nicht', () => {
expect(isCountryAllowed("EU", "AT")).toBe(true);
expect(isCountryAllowed("EU", "DE")).toBe(true);
expect(isCountryAllowed("EU", "CH")).toBe(false);
expect(isCountryAllowed("EU", "US")).toBe(false);
});
test("akzeptiert Kleinschreibung und Leerzeichen aus dem Formular", () => {
expect(isCountryAllowed("EU", " at ")).toBe(true);
expect(isCountryAllowed("DE", "de")).toBe(true);
});
test("ein fehlendes Land ist nie erlaubt", () => {
expect(isCountryAllowed("EU", null)).toBe(false);
expect(isCountryAllowed("EU", "")).toBe(false);
expect(isCountryAllowed("EU", " ")).toBe(false);
});
});
describe("findCustomsExcludedArea", () => {
test("erkennt die beiden deutschen Gebiete ausserhalb des Zollgebiets", () => {
// Der ueberraschende Teil: selbst bei "Nur Deutschland" gibt es
// Anschriften, die eine Zollanmeldung braeuchten.
expect(findCustomsExcludedArea("DE", "27498")).toBe("Helgoland");
expect(findCustomsExcludedArea("DE", "78266")).toBe("Büsingen am Hochrhein");
});
test("laesst normale deutsche Anschriften durch", () => {
expect(findCustomsExcludedArea("DE", "86450")).toBeNull();
expect(findCustomsExcludedArea("DE", "10115")).toBeNull();
});
test("erkennt spanische Sondergebiete", () => {
expect(findCustomsExcludedArea("ES", "51001")).toBe("Ceuta");
expect(findCustomsExcludedArea("ES", "52001")).toBe("Melilla");
expect(findCustomsExcludedArea("ES", "38001")).toBe("Kanarische Inseln");
// Festland Spanien bleibt normaler Binnenversand.
expect(findCustomsExcludedArea("ES", "28001")).toBeNull();
});
test("unterscheidet gleiche Ziffern in verschiedenen Laendern", () => {
// "22" ist in Finnland Åland, in Italien die Provinz Como. Deshalb ist
// die Zuordnung je Land und nicht global.
expect(findCustomsExcludedArea("FI", "22100")).toBe("Åland");
expect(findCustomsExcludedArea("IT", "22100")).toBeNull();
expect(findCustomsExcludedArea("IT", "22061")).toBe("Campione d'Italia");
});
test("erkennt franzoesische Ueberseegebiete", () => {
expect(findCustomsExcludedArea("FR", "97100")).toBe("Französische Überseegebiete");
expect(findCustomsExcludedArea("FR", "75001")).toBeNull();
});
test("Azoren und Madeira sind KEIN Sondergebiet", () => {
// Sie gehoeren voll zum Zoll- und Steuergebiet — normaler Binnenversand.
expect(findCustomsExcludedArea("PT", "9500-001")).toBeNull();
expect(findCustomsExcludedArea("PT", "9000-001")).toBeNull();
});
test("ist unempfindlich gegen Leerzeichen und Kleinschreibung", () => {
expect(findCustomsExcludedArea("de", " 27498 ")).toBe("Helgoland");
expect(findCustomsExcludedArea("NL", "1234 ab")).toBeNull();
});
test("ohne Land oder Postleitzahl gibt es nichts zu melden", () => {
expect(findCustomsExcludedArea(null, "27498")).toBeNull();
expect(findCustomsExcludedArea("DE", null)).toBeNull();
});
});
+143
View File
@@ -0,0 +1,143 @@
/**
* Liefergebiet des Shops.
*
* Bewusst eine ausdrueckliche Entscheidung und keine Ableitung aus den
* Versandstaffeln: ob in ein Land geliefert wird, haengt nicht am Preis,
* sondern an Steuerpflichten (Umsatzsteuer im Bestimmungsland ab der
* EU-Lieferschwelle), Zoll und den eigenen Kapazitaeten. Das gehoert
* entschieden, nicht erraten.
*
* Reine Logik ohne Datenbankzugriff — dieselbe Trennung wie bei
* shipping-calc.ts, damit sie sich ohne DATABASE_URL testen laesst. Den
* aktuell gepflegten Wert liest lib/shipping.ts.
*/
export const SHIPPING_REGIONS = ["DE", "EU"] as const;
export type ShippingRegion = (typeof SHIPPING_REGIONS)[number];
/** Vorgabe: der engste Zuschnitt. Ein Shop liefert nie versehentlich weiter, als er wollte. */
export const DEFAULT_SHIPPING_REGION: ShippingRegion = "DE";
/**
* Die 27 Mitgliedstaaten der Europaeischen Union als ISO-3166-1-alpha-2.
*
* Bewusst die EU und nicht "Europa" im geografischen Sinn: die Schweiz,
* Norwegen und das Vereinigte Koenigreich liegen in Europa, sind aber
* Drittlaender mit Zollanmeldung und Einfuhrumsatzsteuer. Wer sie beliefern
* will, braucht mehr als einen Haken im Admin-Bereich.
*
* Griechenland steht als "GR" (ISO), nicht als "EL" (EU-interne Schreibweise).
*/
export const EU_COUNTRY_CODES = [
"AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI",
"FR", "GR", "HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT",
"NL", "PL", "PT", "RO", "SE", "SI", "SK",
] as const;
/** Wandelt den in der Datenbank gepflegten Text in ein Liefergebiet. */
export function parseShippingRegion(raw: string | null | undefined): ShippingRegion {
const value = raw?.trim().toUpperCase();
return SHIPPING_REGIONS.includes(value as ShippingRegion)
? (value as ShippingRegion)
: DEFAULT_SHIPPING_REGION;
}
/**
* Alle Laender, in die geliefert wird — aufsteigend sortiert, damit die
* Auswahl im Checkout eine stabile Reihenfolge hat.
*/
export function allowedCountryCodes(region: ShippingRegion): string[] {
return region === "EU" ? [...EU_COUNTRY_CODES].sort() : ["DE"];
}
/**
* Gebiete, die zu einem EU-Mitgliedstaat gehoeren, aber ausserhalb des
* Zoll- und/oder Steuergebiets der Union liegen.
*
* Die EU ist eine Zollunion — deshalb braucht eine Sendung von Deutschland
* nach Frankreich keine Zollanmeldung. Fuer diese Gebiete gilt das NICHT:
* dorthin ist jede Sendung eine Ausfuhr mit Zollinhaltserklaerung,
* Handelsrechnung und moeglichen Einfuhrabgaben beim Empfaenger. Ein
* Versandetikett allein reicht nicht.
*
* Erkennbar sind sie nur an der Postleitzahl, nicht am Laendercode — und zwei
* davon liegen in DEUTSCHLAND. Ohne diese Liste braeuchte also selbst die
* Einstellung "Nur Deutschland" gelegentlich eine Zollabwicklung.
*
* Die Zuordnung ist bewusst je Land, weil dieselbe Ziffernfolge anderswo ein
* ganz normales Gebiet bezeichnet: "22" ist in Finnland Åland, in Italien
* aber die Provinz Como.
*
* NICHT enthalten, weil sie voll zum Zoll- UND Steuergebiet gehoeren: die
* Azoren und Madeira (Portugal). Dorthin gilt der normale EU-Binnenversand.
*/
export const CUSTOMS_EXCLUDED_AREAS: {
country: string;
name: string;
/** Vollstaendige Postleitzahlen. */
exact?: string[];
/** Anfaenge von Postleitzahlen. */
prefixes?: string[];
}[] = [
// Beide liegen ausserhalb des EU-Zollgebiets: Buesingen gehoert zum
// Schweizer Zollgebiet, Helgoland zu gar keinem.
{ country: "DE", name: "Helgoland", exact: ["27498"] },
{ country: "DE", name: "Büsingen am Hochrhein", exact: ["78266"] },
// Ceuta und Melilla liegen ausserhalb von Zoll- UND Steuergebiet, die
// Kanaren innerhalb des Zoll-, aber ausserhalb des Steuergebiets — beides
// bedeutet in der Praxis eine Zollanmeldung.
{ country: "ES", name: "Ceuta", prefixes: ["51"] },
{ country: "ES", name: "Melilla", prefixes: ["52"] },
{ country: "ES", name: "Kanarische Inseln", prefixes: ["35", "38"] },
// Livigno liegt ausserhalb des Zollgebiets; Campione d'Italia gehoert seit
// 2020 dazu, bleibt aber ausserhalb des Steuergebiets (es gilt Schweizer
// Mehrwertsteuer). Exakte Postleitzahlen, weil "23" und "22" in Italien
// sonst ganze Provinzen treffen wuerden.
{ country: "IT", name: "Livigno", exact: ["23041"] },
{ country: "IT", name: "Campione d'Italia", exact: ["22061"] },
// Die Übersee-Départements gehoeren zum Zoll-, nicht zum Steuergebiet; die
// Übersee-Gebiete (98) gehoeren zu keinem von beiden.
{ country: "FR", name: "Französische Überseegebiete", prefixes: ["97", "98"] },
// Innerhalb der Zollunion, ausserhalb des Steuergebiets — es gibt eine
// Steuergrenze mit Anmeldepflicht.
{ country: "FI", name: "Åland", prefixes: ["22"] },
{ country: "GR", name: "Berg Athos", exact: ["63086"] },
];
/**
* Liegt diese Anschrift in einem Gebiet, das trotz EU-Zugehoerigkeit eine
* Zollabwicklung erfordert? Gibt den Namen des Gebiets zurueck, sonst null.
*
* Leerzeichen und Kleinschreibung werden abgefangen: Postleitzahlen werden in
* mehreren EU-Laendern mit Leerzeichen geschrieben (Niederlande "1234 AB"),
* und eine Bestellung an einer Schreibweise scheitern zu lassen waere kein
* Schutz, sondern ein Fehler.
*/
export function findCustomsExcludedArea(
country: string | null | undefined,
zip: string | null | undefined
): string | null {
const countryCode = country?.trim().toUpperCase();
const postalCode = zip?.replace(/\s+/g, "").toUpperCase();
if (!countryCode || !postalCode) return null;
for (const area of CUSTOMS_EXCLUDED_AREAS) {
if (area.country !== countryCode) continue;
if (area.exact?.includes(postalCode)) return area.name;
if (area.prefixes?.some((prefix) => postalCode.startsWith(prefix))) return area.name;
}
return null;
}
/**
* Darf in dieses Land geliefert werden?
*
* Gross-/Kleinschreibung und Leerzeichen werden abgefangen: der Wert kommt
* aus einem Formularfeld, und eine Bestellung an einer Schreibweise scheitern
* zu lassen waere kein Schutz, sondern ein Fehler.
*/
export function isCountryAllowed(region: ShippingRegion, country: string | null | undefined): boolean {
const code = country?.trim().toUpperCase();
if (!code) return false;
return allowedCountryCodes(region).includes(code);
}
+19
View File
@@ -1,7 +1,26 @@
import { cache } from "react";
import { prisma } from "@/lib/prisma";
import { resolveShippingCostCents } from "@/lib/shipping-calc";
import { getContentValue } from "@/lib/content";
import { parseShippingRegion, type ShippingRegion } from "@/lib/shipping-region";
import type { ShippingTier } from "@/generated/prisma/client";
/**
* Aktuell gepflegtes Liefergebiet (Admin-Bereich → Inhalte → Firmendaten).
*
* In React-cache() gehuellt wie getCompanyInfo: der Wert wird beim Rendern
* einer Checkout-Seite mehrfach gebraucht (Laenderauswahl im Formular,
* Hinweistext daneben), und das waeren sonst mehrere Abfragen derselben einen
* Zeile. Die Deduplizierung gilt je Request — eine Umstellung im
* Admin-Bereich wirkt sofort.
*/
export const getShippingRegion = cache(async (): Promise<ShippingRegion> => {
// Nicht lokalisiert: das Liefergebiet ist eine Tatsache des Betriebs, keine
// Textfassung. getContentValue liefert fuer solche Schluessel unabhaengig
// von der Sprache denselben Wert.
return parseShippingRegion(await getContentValue("shipping.region", "de"));
});
export async function getShippingTiers(): Promise<ShippingTier[]> {
return prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } });
}