---
topic: coupon
title: Creating, editing and deleting coupons
summary: tkupon via CouponsController — the mass deactivation a save must dodge, the edit that resets every field you omit, and what is not catalogued.
appliesTo:
  - coupon.coupon.create
  - coupon.coupon.update
  - coupon.coupon.delete
derivedFrom:
  - includes/src/Router/Controller/Backend/CouponsController.php:53-136
  - includes/src/Router/Controller/Backend/CouponsController.php:331-431
  - includes/src/Router/Controller/Backend/CouponsController.php:450-543
  - includes/src/Router/Controller/Backend/CouponsController.php:626-695
  - includes/src/Router/Controller/Backend/CouponsController.php:825-867
  - includes/src/Checkout/Kupon.php:228-298
  - includes/src/Checkout/Kupon.php:1109-1198
  - admin/templates/bootstrap/tpl_inc/kupons_bearbeiten.tpl:54-560
  - admin/templates/bootstrap/tpl_inc/kupons_uebersicht.tpl:40-140
---

## Domain model

`tkupon` (PK `kKupon`) is the coupon. Three child tables hang off it by plain `kKupon` — **no FK,
no cascade**: `tkuponsprache` (`kKupon`+`cISOSprache`, one localised `cName` per language),
`tkuponkunde` (`kKuponKunde`, who redeemed it), `tkuponbestellung` (`kKupon`+`kBestellung`, which
orders used it and for how much). `tkuponflag` is never touched here. Only the first two have a
surrogate key, so a restored coupon gets a NEW `kKupon` while restored child rows keep the old one.

`cKuponTyp` is `standard`, `versandkupon` (free shipping) or `neukundenkupon`
(`Kupon.php:139-141`). `cHersteller`, `cKategorien`, `cKunden` and `cArtikel` are semicolon-separated
strings where `'-1'` means "no restriction". `kKundengruppe` uses `-1` for that — **and so does
`0`**: the frontend gate is `kKundengruppe = -1 OR kKundengruppe = 0 OR kKundengruppe = :cgid`
(`Kupon.php:988-990`). Since `Request::pInt('kKundengruppe')` (`:371`) reads an omitted key as `0`,
forgetting the field does not lock the coupon down, it opens it up. Both save entries require it.

Everything runs through one dispatch key inside `Form::validateToken()` (`:66`), which `return`s
**silently** on failure, and feedback is `alertService` only (`:841-865`) — session-scoped, absent
from the response body. `verify.ok` is the only reliable signal; all three entries carry one.

## ⚠️ The collateral write, and how two entries dodge it

`displayOverview()` opens with two unbounded statements (`:635-636`, bodies `:525-543`) —
`UPDATE tkupon SET cAktiv='N' WHERE dGueltigBis > 0 AND dGueltigBis <= NOW()` and
`UPDATE tkupon SET cAktiv='N' WHERE nVerwendungen > 0 AND nVerwendungenBisher >= nVerwendungen`.
Neither is keyed by anything the caller sent, and `displayOverview()` is reached for a bare GET, for
`action=loeschen`, and for `action=speichern` **without** `saveAndContinue` (`:104-106`). As the
browser sends it, "save one coupon" also retires every expired and exhausted coupon in the shop.

`coupon.coupon.create` and `coupon.coupon.update` therefore send `saveAndContinue` as a **catalog
constant**: `:91` still routes to `actionSave()`, and `:97-103` then routes the response to
`displayEditPage()`, so `displayOverview()` never runs. That is why those two can journal `tkupon`
by a key, and why `create` is `Reversible` — it writes only rows it created.

`coupon.coupon.delete` cannot dodge it: `actionDelete()` falls through to the same `else`. Hence its
whole-table journal scope, its `Destructive` class, and a `verify:` clause that asserts the
collateral write instead of pretending it away.

## Reads first

1. `SELECT kKupon, cCode, cName, cKuponTyp, cAktiv, nVerwendungen, nVerwendungenBisher, dGueltigBis
   FROM tkupon` — the source of `kKuponBearbeiten`.
2. Before an edit, `SELECT * FROM tkupon WHERE kKupon = <id>` plus its `tkuponsprache` rows; you will
   resend all of it.
3. Before a delete, the `tkuponbestellung` count per coupon, plus the collateral set — the literal
   `verify:` predicate below, run as a `SELECT`.

## Creating a coupon

`action=speichern` with no `kKuponBearbeiten` → `actionSave()` (`:825-853`) → a `Kupon` built from
`Text::filterXSS($_POST)` (`:331-431`) → `validate()` (`Kupon.php:1109-1198`) → insert (`:450-495`).
Four things to know:

1. **Five keys are dereferenced with no `??`** — `cKuponTyp` (`:335`), `cName` (`:336`),
   `fMindestbestellwert` (`:360`), and the surprising pair `kHersteller` / `kKategorien` (`:401`,
   `:404`). Those two sit inside `if (!\in_array(-1, $manufacturers, true))`, and `in_array(-1, [])`
   is false, so **an omitted `kHersteller` takes the branch that reads it** — an "Undefined array
   key" warning. All five are `required: true`; send `[-1]` for "no restriction".
2. **An omitted `cCode` is not empty, it is random** (`:480-482`), and no `verify:` clause could bind
   a code the shop invented — required by the entry even though the shop would accept it missing.
3. **An omitted `cAktiv` creates an INACTIVE coupon** (`:382`, a presence-and-value test). Required.
4. **`cArtikel` holds article NUMBERS**, looked up in `tartikel.cArtNr` (`Kupon.php:1165-1172`); one
   unknown number fails the whole save.

Validation failure is one error alert holding an HTML `<ul>` of every message (`:833-841`) and
nothing is written. Then `updateLocalizations()` (`:502-520`) writes one `tkuponsprache` row per
installed language, using `cName_<iso>` where given and `cName` otherwise.

## Editing a coupon

Same `action=speichern`, plus `kKuponBearbeiten > 0`. The object starts as
`new Kupon(kKuponBearbeiten)` (`:334`), is then overwritten field by field from the form, and
`Kupon::update()` (`:265-293`) writes all 23 non-PK columns. Only `nVerwendungenBisher` and
`dErstellt` survive. Omit a field and it is reset, not kept:

| omitted | becomes | where |
|---|---|---|
| `cAktiv` | `'N'` | `:382` |
| `kKundengruppe` | `0` — which the frontend reads as "all groups" | `:371` |
| `dGueltigBis` | `NULL`, open end | `:377-381`, `Kupon.php:274` |
| `nVerwendungen`, `nVerwendungenProKunde` | `0`, unlimited | `:365-366` |
| `cHersteller`, `cKategorien`, `cKunden` | `'-1'` | `:370`, `:383`, `:384-386` |
| `cLieferlaender`, `cArtikel` | `''` | `:362-369` |

Hence `Destructive`: it silently rewrites columns nobody named. **It is also the
activate/deactivate action** — `cAktiv` only ever rides this form, there is no other branch.

`nVerwendungenBisher` survives, so lowering `nVerwendungen` below it exhausts the coupon and the next
overview render sets `cAktiv='N'` for you. The `tkuponsprache` rows are rebuilt on every save
(`:505-518`), so a language missing from `cName_{code}` falls back to `cName`.

## Deleting coupons

`action=loeschen` → `actionDelete()` (`:855-867`) → `loescheKupons()` (`:117-136`): ONE
`DELETE tkupon, tkuponsprache, tkuponkunde, tkuponbestellung FROM tkupon LEFT JOIN … WHERE
tkupon.kKupon IN(…)`. That takes the redemption history with it. Nothing else in the shop removes
those rows — `Kupon::delete()` (`Kupon.php:295-298`) touches `tkupon` alone and would orphan them. To
retire a coupon instead, use `coupon.coupon.update` with `cAktiv: "N"`.

### Why its `verify:` is a literal

**An array argument cannot be bound into a verify query.** `ActionInvocation::assert()` passes the
parameters straight to `getSingleObject()` (`ActionInvocation.php:431-449`), the `:name` scan is
`/:([A-Za-z_][A-Za-z0-9_]*)/` (`:713-720`), nothing expands an `IN (…)` list, and `parameter()`
returns `null` for an array (`:523-540`). The only array-aware construct is `{{count(args.X)}}`,
which cannot name particular rows.

So the clause is literal, like `redirect.unassigned.deleteAll`: `SELECT COUNT(*) AS n FROM tkupon
WHERE cAktiv = 'Y' AND ((dGueltigBis > 0 AND dGueltigBis <= NOW()) OR (nVerwendungen > 0 AND
nVerwendungenBisher >= nVerwendungen))`, expecting `0`. That is the exact union of the two `UPDATE`s
at `:525-543`, which this path always runs, so it holds afterwards and need not hold before. It
proves the **collateral** write, not that your coupons are gone — read those back yourself.

## What is deliberately not here

**Mass creation was DROPPED.** `couponCreation` and friends (`:409-428`, `:461-478`) create N coupons
with codes the shop invents, and `validate()` **skips the duplicate-code check entirely** there
(`Kupon.php:1147-1149`). No argument survives into any row, so no `verify:` clause could bind one and
every call would roll back as unverified. Call `coupon.coupon.create` N times with codes you chose.

**No `coupon.*.activate` / `.deactivate`** — that is `coupon.coupon.update` with `cAktiv`.
**`csvImport`** needs `multipart/form-data` the invoker cannot populate and `importType = 0` runs
`TRUNCATE TABLE tkupon` (`:545-624`). **`csvExport`** streams to stdout (`:652-689`) and still takes
the `displayOverview()` path. **`action=bearbeiten`** reads `$_GET['kKupon']` (`:80`) while the
invoker seeds `$_GET` with `token` only (`BackendActionInvoker.php:107`).

## Four failure modes to expect

1. **"Success, and there is no coupon."** Validation failed; the message is one session-scoped alert
   (`:833-841`), so `verify.ok` is where it shows. Usual causes: duplicate `cCode`, unknown article
   number, `versandkupon` with no `cLieferlaender`.
2. **"I edited the name and the coupon stopped working."** Whole-row rewrite — see the table above.
3. **"I deleted one coupon and three others went inactive."** The collateral write; run read 3 first.
4. **"Customer 5 gets a coupon restricted to customer 15."** `cKunden` is stored without a leading
   semicolon (`'15;'`, `:406-408`) and the frontend matches with
   `str_contains($this->cKunden, $kKunde . ';')` (`Kupon.php:1062-1068`), so a suffix id matches. One
   customer per coupon, and report the substring match upstream.
