From 1bdf4df04a02148162b4e1ebc9f5698860e9f411 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 19 Sep 2026 08:03:10 +0200 Subject: [PATCH] feat: Gebiete mit Zollpflicht vom Versand ausgenommen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/api/checkout/route.test.ts | 13 +++++ src/app/api/checkout/route.ts | 18 ++++++- src/i18n/dictionaries/de.ts | 4 +- src/i18n/dictionaries/en.ts | 4 +- src/lib/shipping-region.test.ts | 52 ++++++++++++++++++++ src/lib/shipping-region.ts | 79 ++++++++++++++++++++++++++++++ 6 files changed, 165 insertions(+), 5 deletions(-) diff --git a/src/app/api/checkout/route.test.ts b/src/app/api/checkout/route.test.ts index 6f43d94..259f4b4 100644 --- a/src/app/api/checkout/route.test.ts +++ b/src/app/api/checkout/route.test.ts @@ -213,6 +213,19 @@ describe("Checkout-Route", () => { 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. diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts index 122ed05..39fca4d 100644 --- a/src/app/api/checkout/route.ts +++ b/src/app/api/checkout/route.ts @@ -10,7 +10,7 @@ import { rateLimit, getClientIp } from "@/lib/rate-limit"; import { isTrustedOrigin } from "@/lib/csrf"; import { verifyTurnstileToken } from "@/lib/turnstile"; import { getShippingCostCents, getShippingRegion } from "@/lib/shipping"; -import { isCountryAllowed } from "@/lib/shipping-region"; +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"; @@ -198,6 +198,22 @@ export async function POST(request: Request) { { 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); diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index 9a227f1..f11ebf0 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -392,8 +392,8 @@ export const de: Dictionary = { 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.", - regionTextEu: "Versand ist in die gesamte Europäische Union möglich. Bestellungen mit einer Lieferadresse außerhalb der EU können wir derzeit leider nicht annehmen.", + 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", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 3f682a1..f4dbc27 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -392,8 +392,8 @@ export const en: Dictionary = { 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.", - regionTextEu: "We ship to the entire European Union. Unfortunately we cannot currently accept orders with a delivery address outside the EU.", + 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", diff --git a/src/lib/shipping-region.test.ts b/src/lib/shipping-region.test.ts index 41a9a6e..07a61af 100644 --- a/src/lib/shipping-region.test.ts +++ b/src/lib/shipping-region.test.ts @@ -11,6 +11,7 @@ import { DEFAULT_SHIPPING_REGION, EU_COUNTRY_CODES, allowedCountryCodes, + findCustomsExcludedArea, isCountryAllowed, parseShippingRegion, } from "./shipping-region"; @@ -89,3 +90,54 @@ describe("isCountryAllowed", () => { 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(); + }); +}); diff --git a/src/lib/shipping-region.ts b/src/lib/shipping-region.ts index 5ec0e34..65aa5ab 100644 --- a/src/lib/shipping-region.ts +++ b/src/lib/shipping-region.ts @@ -50,6 +50,85 @@ 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? *