---
topic: shipping-surcharge
title: Per-country ZIP surcharges on a shipping method
summary: Four adminIo actions over tversandzuschlag and its ZIP rows — overlap is refused, deletes are silent, and only the read-back tells the truth.
appliesTo:
  - shipping.surcharge.save
  - shipping.surcharge.zip.add
  - shipping.surcharge.delete
  - shipping.surcharge.zip.delete
derivedFrom:
  - includes/src/Router/Controller/Backend/IOController.php:560-606
  - includes/src/Router/Controller/Backend/IOController.php:618-671
  - includes/src/Router/Controller/Backend/IOController.php:685-746
  - includes/src/Checkout/Versandart.php:97-105
  - admin/templates/bootstrap/snippets/zuschlagliste_form.tpl
  - admin/templates/bootstrap/tpl_inc/versandarten_zuschlagsliste.tpl
---

## Domain model

A surcharge is a `tversandzuschlag` row: one shipping method (`kVersandart`), one country
(`cISO`), a name and an amount (`fZuschlag`). Under it hang `tversandzuschlagsprache` (localised
names) and `tversandzuschlagplz` — the ZIP codes and ZIP ranges the surcharge applies to. A
surcharge with no ZIP rows applies to nothing.

All four actions are `kind: adminIo`: they call a public method on `IOController` directly rather
than posting a form. That is deliberate — routing them through `IOController::getResponse()` would
re-register the whole IO function table and fail on the second call in a process (`IO.php:59-61`).

Surcharges are looked up **per country**, through
`Versandart::getShippingSurchargesForCountry()`. `Versandart::load()` memoises the whole method
including its surcharges (`Versandart.php:97-105`), so rows written straight into the tables stay
invisible until `CACHING_GROUP_OPTION` is flushed. The actions themselves flush on the way out.

## Before you start

- shipping methods: `SELECT kVersandart, cName FROM tversandart`
- existing surcharges: `SELECT kVersandzuschlag, kVersandart, cISO, cName FROM tversandzuschlag`
- existing ZIP rows: `SELECT kVersandzuschlag, cPLZ, cPLZAb, cPLZBis FROM tversandzuschlagplz`

The `cISO` you choose must be a country the shipping method actually serves, or the surcharge is
never consulted.

## Creating or updating a surcharge — `shipping.surcharge.save`

1. Pick `kVersandart` and the `cISO` country the surcharge applies to.
2. Send `kVersandzuschlag: 0` to create, or an existing id to update.
3. `cName` and a non-zero `fZuschlag` are both mandatory.
4. Optionally send `cName_` as a map of language code to localised name (`ger`, `eng`).
5. Call `invoke_admin_action`, then check `verify.ok`.

**An update ignores `cISO` and `kVersandart`** (`:583-585`). They are only read on the create
branch, when `kVersandzuschlag` is empty (`:577-581`). To move a surcharge to another country you
must delete it and create it again.

**`fZuschlag: 0` is treated as missing, not as "no surcharge"** (`:565-568`). Comma and dot are
both accepted; the shop normalises.

**This method never throws on a validation failure.** It returns `error: true` with an HTML alert
in `message`, so a transport-level success proves nothing. An empty `cName` is the one branch that
fires before any write (`:570-572`) and alerts `errorListNameMissing`.

## Adding a ZIP code or range — `shipping.surcharge.zip.add`

1. Look up `kVersandzuschlag`.
2. Send EITHER `cPLZ` (a single code) OR `cPLZAb` + `cPLZBis` (a range). If both are sent, `cPLZ`
   wins (`:701`).
3. Call `invoke_admin_action`, then check `verify.ok`.

Sending neither raises `errorZIPMissing` (`:728`). A range whose two ends carry different letter
prefixes raises `errorZIPsDoNotMatch` (`:721-722`). The ZIP format itself is validated against the
surcharge's country by `ZipValidator`, built from `tversandzuschlag.cISO` (`:692`) — `DE` is five
digits.

**Overlap is refused, not merged.** A ZIP or range already covered by any surcharge of the same
shipping method and country raises `errorZIPOverlap` and inserts nothing (detection `:712-720`,
alert `:730-738`). This is the shop's own rule, evaluated over its own cached view of the method —
which is why a row seeded straight into the table without a cache flush will not be seen.

**A rejected ZIP still looks like a successful call.** The cache is flushed on every call including
the rejected ones (`:742-746`), and the return value carries no failure flag. The read-back is the
only machine-readable signal.

## Deleting — `shipping.surcharge.delete` and `shipping.surcharge.zip.delete`

Both are `Destructive`: they need `confirm: true` and are refused in `limited` mode.

`shipping.surcharge.delete` takes `surchargeID` and issues ONE statement that deletes from all
three tables — the surcharge, all its localised names and all its ZIP rows (`:620-627`). There is
no per-ZIP variant here.

`shipping.surcharge.zip.delete` takes `surchargeID` and `ZIP`, and parses `ZIP` with
`explode('-', $ZIP)` (`:639`):

- **one part** → deletes the row whose `cPLZ` equals it (`:640-650`)
- **two parts** → deletes the row whose `cPLZAb`/`cPLZBis` equal them (`:651-663`)
- **three or more** → matches neither branch, flushes the cache, returns success, deletes nothing

**The range separator is a bare hyphen with no spaces**: `10000-19999`. `10000 - 19999` splits into
three parts and silently does nothing; an en dash `10000–19999` is not a separator at all, so the
DELETE looks for a `cPLZ` literally equal to that string. A ZIP containing a hyphen of its own is
read as a range, because `explode()` is unconditional.

Neither delete confirms anything: the returned object just echoes `{surchargeID}` or
`{surchargeID, ZIP}` (`:671`). An unknown id deletes nothing and still reports success. Only the
read-back proves the row is gone.

## What is not reachable from here

- **Creating the shipping method** itself — see the `shipping` guide.
- **Editing a ZIP row in place.** There is no update; delete the row and add it again.
- **Merging overlapping ranges.** The shop refuses the overlap; it will not widen an existing range
  for you.
- **Undoing a cache flush.** Every one of these actions flushes cache tags, including on the
  branches that write nothing. A `dryRun` rolls back the rows only.

## Undoing a surcharge change

Every successful `invoke_admin_action` returns a `changeId` plus an `undo` block. Pass it to
`undo_admin_action({changeId})` for the plan, and again with `confirm: true` to apply it — a ZIP
row added here is removed again, a deleted surcharge is re-inserted. The undo is refused as
`superseded` when the row has been edited since. The cache flush is not undone.
