---
topic: payment
title: Configuring payment methods and clearing their logs
summary: The three invocable branches of PaymentMethodsController — what a save really writes, why availability is derived, and what a log reset actually scopes to.
appliesTo:
  - payment.method.saveConfig
  - payment.method.checkAvailability
  - payment.method.logReset
derivedFrom:
  - includes/src/Router/Controller/Backend/PaymentMethodsController.php:53
  - includes/src/Router/Controller/Backend/PaymentMethodsController.php:80-122
  - includes/src/Router/Controller/Backend/PaymentMethodsController.php:189-220
  - includes/src/Router/Controller/Backend/PaymentMethodsController.php:378-460
  - includes/src/Helpers/PaymentMethod.php:241-269
  - includes/src/Checkout/PaymentLog/PaymentLogService.php:45
  - includes/src/Checkout/PaymentLog/PaymentLogService.php:120-125
  - includes/src/Language/LanguageHelper.php:56
  - admin/templates/bootstrap/tpl_inc/zahlungsarten_einstellen.tpl
---

## Domain model

A payment method is three things at once and only the first is a row you can edit here.
`tzahlungsart` is the method itself — `kZahlungsart`, `cName`, `cModulId`, `nActive`, `nNutzbar`,
`nSort`, `cKundengruppen`, `nMailSenden`, `cBild`, `nWaehrendBestellung`. `tzahlungsartsprache` is
its per-language text, one row per **active** language (`cName`, `cGebuehrname`, `cHinweisText`,
`cHinweisTextShop`). The module's own settings live in `teinstellungen`, keyed by `cWertName`, and
which keys exist is declared in `teinstellungenconf WHERE cModulId = <the method's cModulId>` — a
different set for every payment module, so this catalog cannot enumerate them.

`cModulId` is the join between all three, and it is **not unique per method**. Two `tzahlungsart`
rows can carry the same module id; they then share one settings block and one log.

Three branches of `PaymentMethodsController` are invocable and shipped:
`einstellungen_bearbeiten=1` (save config, `:112-122`), `checkNutzbar=1` (re-derive availability,
`:80-83`) and `a=logreset` (clear the module's payment log, `:85-101`). Everything else the
controller exposes is a view step, not a write.

## Reads first

1. `list_payment_methods` — the id, the module id, and whether anyone can actually pay with it
   (`active` is configured, `usable` is derived).
2. `get_payment_method` — the same method in full: its module settings with their declared keys and
   allowed values, both mail bits, the per-language rows and the shipping methods offering it. A save
   rewrites all of that from what it is sent, so this read IS the payload you edit. Secret-looking
   values come back as `***` — send the real value, not the redaction.
3. `run_sql_read "SELECT kSprache, cISO, cNameDeutsch, active FROM tsprache"` — a save writes one
   `tzahlungsartsprache` row per **active** language and dereferences four POST keys per language
   without a null check.
4. `run_sql_read "SELECT kKundengruppe, cName FROM tkundengruppe"` — `kKundengruppe[]` is required on
   every save; `0` in the list means "all groups".

## Walkthrough — saving a payment method's configuration

`payment.method.saveConfig` is an **update-only** endpoint. There is no insert path in this
controller; new payment methods arrive by installing a plugin.

Step 1 — resolve the method (read 1) and its settings keys (read 2). If `kZahlungsart` names no row,
`actionSaveConfig()` throws `InvalidArgumentException` (`PaymentMethodsController.php:381-384`) —
that is a 500, not an alert.

Step 2 — build the arguments. `einstellungen_bearbeiten=1` **and** `kZahlungsart>0` are both required
for the branch to fire at all (`:113-114`). Then:

- `kKundengruppe[]` — read with a bare `is_array()` and no `??` (`:392`). Any list containing `0`
  wipes the restriction entirely (`:395-397`), which is how "all customer groups" is expressed.
- `cBild` — dereferenced without `??` at `:415`. Omitting it is a PHP warning, not a validation
  error.
- `nMailSenden` and `nMailSendenStorno` — two POST keys, **one column**. They are OR-ed into
  `nMailBits` (`:399-404`) and written to `tzahlungsart.nMailSenden` (`:414`). Sending one and
  omitting the other clears the other. Always send both, always as the pair you read back.
- `nSort` — optional in the sense that the catalog does not require it, but it is written
  unconditionally with `Request::pInt('nSort')` (`:413`). Omit it and the method silently sorts to 0.
- `nWaehrendBestellung` — the only field that falls back to the row's current value (`:409`).

Step 3 — the language block. For every **active** language iso (`getAllLanguages(0, true, true)`,
`:438`, third argument `onlyActive`) send `cName_{iso}`, `cGebuehrname_{iso}`, `cHinweisText_{iso}`
and `cHinweisTextShop_{iso}`. The last three are dereferenced without `??` (`:445-447`). `cName_{iso}`
is the one that falls back — to `tzahlungsart.cName`, not to the old localized value (`:441-443`) —
but the key must still exist. Each language is a `DELETE` + `INSERT` (`:449-454`), so a missing
language key is a lost row, not an unchanged one.

Step 4 — the module settings. Send the `cWertName`s from read 2 back under their raw names. The
controller decides for itself whether the method is a plugin method (`cModulId` contains `kPlugin_`)
and injects `kPlugin` or `cModulId` into the payload before calling `Section::update($post)`
(`:419-435`). Note that `$post` there is raw `$_POST`, not the filtered array the rest of the method
uses, and that `$section->load()` is called only on the non-plugin branch (`:431`).

Step 5 — validate first. This entry is **not** `dryRunnable`: no rehearsal, and the real save
flushes the whole cache. `validate_admin_action({id, args})` reads only — it refuses ids missing
from `tzahlungsart`/`tkundengruppe` and catches both pairs above. Prose rules land in `unchecked`;
`valid: true` only means nothing checkable is wrong.

Step 6 — invoke, then check `verify.ok`, not just `ok`. The verify clause asserts on `cBild` rather
than `nSort` on purpose: `nSort` is optional, and a verify placeholder pointing at an argument the
caller legitimately omitted cannot be bound, which `ActionInvocation` reads as "did not happen" and
rolls the whole save back.

## Walkthrough — the other two

**`payment.method.checkAvailability`** takes no arguments. It recomputes `nNutzbar` for **every**
active method by calling `Helpers/PaymentMethod::activatePaymentMethod()` per row (`:241-269`).
`nNutzbar` is derived from PHP's SOAP/cURL/socket support (`:246-257`) and from nothing you can post.
Read the result back with read 1 and compare.

**`payment.method.logReset`** deletes `tzahlungslog` rows by the method's **`cModulId`**, not by its
`kZahlungsart` (`:90-92` → `PaymentLogService::deleteLogEntries()`). Resolve the `cModulId` first and
count what you are about to destroy:
`run_sql_read "SELECT nLevel, COUNT(*) FROM tzahlungslog WHERE cModulId = :m GROUP BY nLevel"`.
This entry is `Destructive` — pass `confirm: true`. On success the controller answers with a 302 and
the alert key `successLogReset`.

## Three most common failures

1. **Sending `nMailSenden` alone.** The bitmask (`:400`, `:403`) is rebuilt from zero on every save,
   so the omitted half is cleared, not preserved. See Step 2; `validate_admin_action` catches it.

2. **Treating "one method's log" as one method's.** Two `tzahlungsart` rows sharing a `cModulId`
   share one `tzahlungslog`. `logReset` deletes **all** levels (`PaymentLogService.php:45` only
   filters when `level > 0`) with no date cutoff, for every method on that module.

3. **Expecting a per-method availability repair.** `checkAvailability` has no scope argument and no
   way to gain one. Worse, `Helpers/PaymentMethod.php:259` compares a DB string to an int with
   `!==`, so the `UPDATE` fires for every active row on every call. Expect writes in the journal. `za_null_jtl` — the "no
   payment required" fallback — is forced to `nNutzbar = 0` (`:250-258`); that is correct, not a
   failure.

## What you cannot do here

**You cannot create a payment method.** `actionSaveConfig()` only ever `UPDATE`s (`:417`). Methods
come from plugins.

**You cannot activate one.** `nActive` is never written by this controller, on any branch. Activation
is a different screen.

**You cannot delete one, in practice.** `stepDelete()` (`:189-220`) deletes only when the method's
plugin loader throws (`:196-208`); a built-in `za_*_jtl` method has `pluginID` 0, so the branch
writes and alerts nothing — an unreachable no-op, deliberately not catalogued.

**A save cannot be fully undone.** `cache->flushAll()` (`:457`) drops the entire shop cache, not a
tag: the rows come back, the cache does not, and the next few page loads are slow. Batch edits
rather than looping. `teinstellungenlog` (`Manager::addLog()`) is not rewritable either, by design.

**Every payment action pays a hidden garbage collection.** Constructing this controller runs
`TransactionalPaymentLogService::garbageCollect()` (`:53`), dropping transactional log rows older
than 30 minutes — on every branch, never scoped to your arguments.

**`checkNutzbar` is the one branch with no CSRF check** (`:80` has no `Form::validateToken()`, unlike
`:88`, `:102` and `:115`). The invoker mints and submits a token anyway; do not read the absence as
permission to call it from anywhere.

## Undoing a payment change

`invoke_admin_action` returns a `changeId` and an `undo` block; `undo_admin_action({changeId})`
returns the plan and applies nothing without `confirm: true`. It restores the journalled rows in
`tzahlungsart`, `tzahlungsartsprache` and the module's `teinstellungen` keys, and refuses as
`superseded` if any of them has changed since. Cache flushes and sent mail are not undone.
