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? *