Zahlungsparser

This commit is contained in:
2026-09-09 16:55:08 +02:00
parent ae13841699
commit 651b6147bf
38 changed files with 3308 additions and 22 deletions
@@ -69,6 +69,37 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule
return $this->allRequiredFilled($this->getOptions(), $config);
}
/**
* Optionen mit `scope => 'tenant'`: Sie gelten für den ganzen Mandanten und werden **nicht** pro
* Veranstaltung eingefroren.
*
* Beispiel ist das Kontoauszug-Format: Es beschreibt die Bank, nicht die Zusage an die
* Teilnehmenden. Läge es im Event-Snapshot, ließe sich nach einem Bankwechsel für laufende
* Aktionen nichts mehr importieren, bis das Format bei jeder einzeln nachgezogen wurde.
* IBAN und Kontoinhaber bleiben dagegen weiterhin pro Aktion eingefroren.
*
* @return array<int, string>
*/
public function tenantScopedOptionKeys(): array
{
return array_values(array_map(
static fn (array $option) => $option['name'],
array_filter($this->getOptions(), static fn (array $option) => ($option['scope'] ?? null) === 'tenant')
));
}
/**
* Entfernt die tenant-weiten Optionen aus einer Konfiguration -- angewandt auf den
* Copy-on-Assign-Snapshot einer Veranstaltung.
*
* @param array<string, mixed> $config
* @return array<string, mixed>
*/
public function stripTenantScopedOptions(array $config): array
{
return array_diff_key($config, array_flip($this->tenantScopedOptionKeys()));
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
@@ -84,6 +115,24 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule
return $this->allRequiredFilled($this->getParticipantOptions(), $input);
}
/**
* Die Teilnehmer-Optionen, die im Anmeldeformular tatsächlich abgefragt werden.
*
* Optionen mit `system => true` fallen heraus: Sie liegen zwar in `payment_options`, entstehen aber
* im Programm statt durch eine Eingabe -- das Zahler-Konto etwa trägt der Import aus dem
* Kontoauszug nach. Im Schema müssen sie trotzdem stehen, sonst verwirft
* {@see sanitizeParticipantOptions()} sie als unbekannte Schlüssel.
*
* @return array<int, array{name: string, label: string, type: string, required: bool}>
*/
public function participantInputOptions(): array
{
return array_values(array_filter(
$this->getParticipantOptions(),
static fn (array $option): bool => ($option['system'] ?? false) !== true,
));
}
/**
* @param array<int, array{name: string, required?: bool}> $schema
* @return array<int, string>
+42 -4
View File
@@ -15,7 +15,7 @@ Rechnung) liegt gekapselt in einem Modul pro Zahlungsart. Aufgelöst wird über
- `DTO/` — geteilte Request/Response-DTOs je Operation (`DoPayment*`, `CreateInvoice*`, `RegistrationSummary*`).
- `EventPaymentModuleRegistry` — statische Map `slug → Modul-Instanz` (`forSlug()`, `all()`, `slugs()`). **Neue Module
hier eintragen.** Kein Container-Binding.
- `ProvidesGiroCode`**Fähigkeits-Interface** (siehe unten).
- `ProvidesGiroCode`, `ProvidesStatementRuleset`, `ReadsBankStatements`**Fähigkeits-Interfaces** (siehe unten).
## Kernregeln
@@ -32,6 +32,15 @@ Rechnung) liegt gekapselt in einem Modul pro Zahlungsart. Aufgelöst wird über
generische `SignUpForm/components/PaymentMethodInputs.vue` schema-getrieben.
- Optionaler Schlüssel `'hint'` je Option: erklärender Hilfetext, den die Admin-Render-Stellen unter dem Feld anzeigen
(z.B. bei `payment_information`, dass der Text am Anmeldeende + in der Mail erscheint).
- Optionaler Schlüssel `'scope' => 'tenant'` (nur `getOptions()`): Die Option gilt für den **ganzen Mandanten** und
wird **nicht** pro Event eingefroren. Abgeleitet über `tenantScopedOptionKeys()` /
`stripTenantScopedOptions()`, angewandt beim Copy-on-Assign in `SetPaymentMethodsCommand` und ausgefiltert in
`ParticipationFees.vue`. Einziger Fall: `statement_ruleset` (s.u.).
- Optionaler Schlüssel `'system' => true` (nur `getParticipantOptions()`): Das Feld liegt zwar in
`payment_options`, wird aber **nicht im Anmeldeformular abgefragt** — es entsteht im Programm. Gefiltert wird
serverseitig in `AbstractEventPaymentModule::participantInputOptions()`, an das `PaymentMethod::
participantOptionsFor()` (und damit die Resource/das Frontend) delegiert. Im Schema müssen die Felder trotzdem
stehen, sonst verwirft `sanitizeParticipantOptions()` sie als unbekannte Schlüssel.
- `defaultConfiguration()` je Modul liefert die Start-Config beim Anlegen der Tenant-Instanz (`CreateTenantAction`),
aktuell das Default-Symbol (`icon`): Überweisung `building-columns`, Sonstiges `coins`.
- **Aktivierungs-Guard:** Eine Tenant-Zahlungsmethode darf nur `active` werden, wenn alle `required`-Optionen befüllt
@@ -77,6 +86,31 @@ Beispiel GiroCode (nur Überweisung):
- Künftige Verfahren würden analog eigene Fähigkeiten mitbringen (z.B. `ProvidesRedirect` für PayPal,
`ProvidesMandate` für SEPA-Lastschrift) — **erst modellieren, wenn tatsächlich gebraucht.**
### Kontoauszug-Import (zwei Interfaces, bewusst getrennt)
- `ProvidesStatementRuleset::statementRuleset(array $configuration): BankStatementRuleset` — „dieses Modul pflegt das
CSV-Format der Bank". Implementiert **nur** von `AccountTransferPaymentModule`. Das Format ist eine Eigenschaft der
**Bank**, nicht der Zahlungsart: eine Bank, ein Export, ein Ruleset. Das kommende Lastschrift-Modul liest denselben
Auszug und implementiert dieses Interface **nicht** — sonst wäre dasselbe Format zweimal zu pflegen und nach dem
nächsten Bankwechsel eine der beiden Stellen vergessen.
- `ReadsBankStatements` — „dieses Modul kann Umsätze verwerten": `isRelevantTransaction()` (Überweisung: Gutschriften;
Lastschrift später: Belastungen und Rücklastschriften), `matchTransaction()` (Überweisung: Verwendungszweck, Namen,
bekannte Zahler-IBAN, Betrag; Lastschrift später: Mandatsreferenz), `recordTransaction()` (Überweisung: Zahler-Konto
in `payment_options` + `refund_data`). Nur diese drei Entscheidungen sind zahlartspezifisch.
- **Ablage des Rulesets:** App-Standard in `config/bankStatement.php` (GLS Gemeinschaftsbank), Tenant-Override in der
Modul-Option `statement_ruleset` (`type: 'bank-ruleset'`, `scope: 'tenant'`). Der Override gilt **ganz oder gar
nicht** — kein feldweiser Merge, sonst bekäme man beim Umstellen des Trennzeichens weiterhin die Spaltennamen der
GLS untergeschoben.
- **Kandidaten schließen Abgemeldete ein** (`EventParticipantRepository::getForPaymentMatching()`): Wer den Beitrag
überwiesen und sich danach abgemeldet hat, steht trotzdem im Kontoauszug. Die Zahlung wird erfasst — erst dann gibt
es etwas zu erstatten. Die Prüfansicht weist die Abmeldung aus (`isSignedOff`/`signedOffAt`).
- **Modul-Schicht bleibt DB-frei:** Die Konfiguration wird hereingereicht, die Kandidaten für `matchTransaction()`
ebenfalls. Geholt wird beides vom `PaymentMethodRepository` bzw. `EventParticipantRepository`, verdrahtet in den
Actions `ParseBankStatement` / `BookBankStatementPayments` (Domain `Event`). `doPayment()` ist **nicht** beteiligt:
das stößt eine Zahlung an, hier wird eine bereits erfolgte nachgetragen.
- Parser (`App\Providers\BankStatementParseProvider`), `BankStatementRuleset` und `BankTransaction` liegen außerhalb
dieser Schicht — sie sind zahlartneutral.
## Anmelde-Zusammenfassung / Mail-Anzeige
- `registrationSummary(RegistrationSummaryRequest): RegistrationSummaryResponse` liefert den zahlungsspezifischen
@@ -118,6 +152,10 @@ Live-Inbetriebnahme einmal gegen die Produktionsdatenbank ausführen — ersetzt
## Tests
`tests/Unit/PaymentMethodOptionsTest`, `tests/Unit/EventPaymentModuleRegistryTest`,
`tests/Unit/RegistrationSummaryTest`, `tests/Feature/PaymentMethodConfigurationTest`,
`tests/Feature/EventParticipantPaymentSummaryTest`. Ausführung im Container (PHP 8.5):
`docker exec mareike-mareike-app-1 php artisan test`.
`tests/Unit/RegistrationSummaryTest`, `tests/Unit/BankStatementParseTest`, `tests/Unit/BankStatementMatchTest`,
`tests/Unit/BankStatementRulesetTest`, `tests/Feature/PaymentMethodConfigurationTest`,
`tests/Feature/EventParticipantPaymentSummaryTest`, `tests/Feature/BankStatementImportTest`.
Ausführung im Container (PHP 8.5). `php artisan test` läuft im 128-MB-Limit auf `config/postCode.php` in einen
Speicherfehler, deshalb direkt über PHPUnit mit angehobenem Limit:
`docker exec mareike-mareike-app-1 php -d memory_limit=1G vendor/bin/phpunit`
@@ -0,0 +1,25 @@
<?php
namespace App\EventPaymentModules\DTO;
use App\Models\EventParticipant;
/**
* Ergebnis von {@see \App\EventPaymentModules\ReadsBankStatements::matchTransaction()}:
* die erkannte Anmeldung samt Einschätzung, wie belastbar die Erkennung ist.
*
* Die Einschätzung geht in die Prüfansicht und ist dort der Unterschied zwischen „kann man
* durchwinken" und „bitte einmal ansehen". Ein Vorschlag ersetzt nie die Bestätigung durch einen
* Menschen -- gebucht wird nur, was in der Prüfansicht stehen geblieben ist.
*/
final class TransactionMatch
{
public const string CONFIDENCE_CERTAIN = 'sicher';
public const string CONFIDENCE_UNCERTAIN = 'unsicher';
public function __construct(
public readonly EventParticipant $participant,
public readonly string $confidence = self::CONFIDENCE_CERTAIN,
) {
}
}
@@ -7,16 +7,34 @@ use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\EventPaymentModules\DTO\RegistrationRenderContext;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
use App\EventPaymentModules\DTO\TransactionMatch;
use App\EventPaymentModules\ProvidesGiroCode;
use App\EventPaymentModules\ProvidesStatementRuleset;
use App\EventPaymentModules\ReadsBankStatements;
use App\Models\EventParticipant;
use App\Models\PaymentMethod;
use App\Providers\GiroCodeProvider;
use App\Support\Iban;
use App\ValueObjects\Amount;
use App\ValueObjects\BankStatementRuleset;
use App\ValueObjects\BankTransaction;
use Illuminate\Support\Collection;
/**
* Überweisung auf das Veranstaltungskonto -- die Zahlung erfolgt manuell durch die teilnehmende Person.
*/
class AccountTransferPaymentModule extends AbstractEventPaymentModule implements ProvidesGiroCode
class AccountTransferPaymentModule extends AbstractEventPaymentModule implements
ProvidesGiroCode,
ProvidesStatementRuleset,
ReadsBankStatements
{
/** Admin-Option, unter der das Kontoauszug-Format des Mandanten liegt. */
public const string OPTION_STATEMENT_RULESET = 'statement_ruleset';
/** Teilnehmer-Optionen, die der Zahlungsimport nachträgt (nicht im Anmeldeformular). */
public const string OPTION_PAYER_ACCOUNT_OWNER = 'payer_account_owner';
public const string OPTION_PAYER_IBAN = 'payer_iban';
public static function slug(): string
{
return PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION;
@@ -40,18 +58,228 @@ class AccountTransferPaymentModule extends AbstractEventPaymentModule implements
['name' => 'bic', 'label' => 'BIC', 'type' => 'string', 'required' => false],
['name' => 'summary_confirmation_text', 'label' => 'Bestätigung durch Teilnehmende (Zusammenfassung, {amount} als Platzhalter)', 'type' => 'string', 'required' => false],
['name' => 'icon', 'label' => 'Symbol', 'type' => 'icon', 'required' => false],
[
'name' => self::OPTION_STATEMENT_RULESET,
'label' => 'Format des Kontoauszugs (CSV)',
'type' => 'bank-ruleset',
'required' => false,
'scope' => 'tenant',
'hint' => 'Nur ausfüllen, wenn eure Bank ein anderes Format liefert als der App-Standard. '
. 'Die Einstellung gilt für alle Aktionen -- auch für bereits laufende.',
],
];
}
/**
* Beim Zahlungsimport nachgetragen, nicht beim Anmelden abgefragt: `system => true` blendet die
* Felder im Anmeldeformular aus. Im Schema müssen sie trotzdem stehen, sonst verwirft
* {@see sanitizeParticipantOptions()} sie als unbekannte Schlüssel.
*/
public function getParticipantOptions(): array
{
return [
[
'name' => self::OPTION_PAYER_ACCOUNT_OWNER,
'label' => 'Kontoinhaber*in der Zahlung',
'type' => 'string',
'required' => false,
'system' => true,
],
[
'name' => self::OPTION_PAYER_IBAN,
'label' => 'IBAN der Zahlung',
'type' => 'string',
'required' => false,
'system' => true,
],
];
}
public function statementRuleset(array $configuration): BankStatementRuleset
{
$override = $configuration[self::OPTION_STATEMENT_RULESET] ?? null;
// Das Frontend schickt das Ruleset als JSON-String durch das generische Options-Formular.
if (is_string($override)) {
$decoded = json_decode($override, true);
$override = is_array($decoded) ? $decoded : null;
}
return BankStatementRuleset::fromConfiguration(is_array($override) ? $override : null);
}
/** Bei der Überweisung zählen Gutschriften -- Belastungen sind Ausgaben der Aktion. */
public function isRelevantTransaction(BankTransaction $transaction): bool
{
return $transaction->isCredit();
}
/**
* Erkennt die Anmeldung zu einem Zahlungseingang.
*
* Die Regeln greifen in fester Reihenfolge und liefern nur dann etwas, wenn **genau eine**
* Anmeldung passt. Bei zwei Treffern -- zwei Geschwister mit gleichem Nachnamen, zwei
* Namensgleiche im selben Lager -- gibt es bewusst keinen Vorschlag: die falsche Zuordnung wäre
* in der Prüfansicht nicht zu erkennen und würde durchgewinkt.
*
* @param Collection<int, EventParticipant> $candidates
*/
public function matchTransaction(BankTransaction $transaction, Collection $candidates): ?TransactionMatch
{
$purpose = $this->normalize($transaction->purpose);
$payerName = $this->normalize($transaction->payerName);
$payerIban = Iban::normalize($transaction->payerIban);
// 1) Der beim Anmelden erzeugte Verwendungszweck steht unverändert im Auszug -- der Normalfall.
$match = $this->onlyOne($candidates, function (EventParticipant $participant) use ($purpose): bool {
$reference = $this->normalize((string) $participant->payment_purpose);
return $reference !== '' && str_contains($purpose, $reference);
});
if ($match !== null) {
return new TransactionMatch($match);
}
// 2) Verwendungszweck abgetippt oder gekürzt, aber beide Namen sind noch drin.
$match = $this->onlyOne($candidates, function (EventParticipant $participant) use ($purpose): bool {
$firstname = $this->normalize((string) $participant->firstname);
$lastname = $this->normalize((string) $participant->lastname);
return $firstname !== '' && $lastname !== ''
&& str_contains($purpose, $firstname) && str_contains($purpose, $lastname);
});
if ($match !== null) {
return new TransactionMatch($match);
}
// 3) Folgezahlung: Von diesem Konto kam bereits ein Beitrag für genau eine Anmeldung.
if ($payerIban !== '') {
$match = $this->onlyOne($candidates, static function (EventParticipant $participant) use ($payerIban): bool {
$known = (string) (($participant->payment_options ?? [])[self::OPTION_PAYER_IBAN] ?? '');
return $known !== '' && Iban::normalize($known) === $payerIban;
});
if ($match !== null) {
return new TransactionMatch($match);
}
}
// 4) Nur der Nachname im Zweck, aber der Betrag trifft den offenen Rest auf den Cent.
$match = $this->onlyOne($candidates, function (EventParticipant $participant) use ($purpose, $transaction): bool {
$lastname = $this->normalize((string) $participant->lastname);
if ($lastname === '' || !str_contains($purpose, $lastname)) {
return false;
}
return round($this->amountLeft($participant)->getAmount(), 2) === round($transaction->amount->getAmount(), 2);
});
if ($match !== null) {
return new TransactionMatch($match);
}
// 5) Der Zweck sagt nichts, aber das Konto läuft auf den Namen der Anmeldung. Das trägt oft,
// liegt aber bei Eltern- und Gemeinschaftskonten auch daneben -- deshalb nur „unsicher".
if ($payerName !== '') {
$match = $this->onlyOne($candidates, function (EventParticipant $participant) use ($payerName): bool {
$firstname = $this->normalize((string) $participant->firstname);
$lastname = $this->normalize((string) $participant->lastname);
return $lastname !== '' && $payerName === $firstname . $lastname;
});
if ($match !== null) {
return new TransactionMatch($match, TransactionMatch::CONFIDENCE_UNCERTAIN);
}
}
return null;
}
/**
* Hält fest, von welchem Konto der Beitrag kam.
*
* Das ist mehr als eine Notiz: Erstattet wird ausschließlich auf das Konto, von dem der Beitrag
* gezahlt wurde. Bisher muss die teilnehmende Person das selbst zusichern, weil die App es nicht
* weiß -- mit `refund_data` weiß sie es.
*
* Eine IBAN, die die Prüfziffer nicht besteht, wird nicht übernommen: Auf ein Konto mit
* Zahlendreher zu erstatten hieße, das Geld an eine fremde Person zu überweisen.
*/
public function recordTransaction(EventParticipant $participant, BankTransaction $transaction): void
{
$iban = Iban::normalize($transaction->payerIban);
$owner = trim($transaction->payerName);
if ($iban === '' || !Iban::isValid($iban)) {
return;
}
$options = $participant->payment_options ?? [];
// Die jüngste Zahlung gewinnt: Erstattet wird auf das Konto, von dem der Beitrag kam --
// zahlt jemand die zweite Rate von einem anderen Konto, ist das nun dieses.
$options[self::OPTION_PAYER_IBAN] = $iban;
// Einen bereits bekannten Kontoinhaber nicht durch einen leeren Namen ersetzen: Manche Banken
// lassen das Feld bei Folgezahlungen leer, und ohne Inhaber ist die IBAN für die Erstattung
// wertlos.
if ($owner !== '') {
$options[self::OPTION_PAYER_ACCOUNT_OWNER] = $owner;
}
$participant->payment_options = $options;
// Nur hochsetzen, nie zurück: Was wir einmal wissen, wissen wir.
if (($options[self::OPTION_PAYER_ACCOUNT_OWNER] ?? '') !== '') {
$participant->refund_data = true;
}
}
/**
* Genau ein Treffer oder nichts.
*
* @param Collection<int, EventParticipant> $candidates
* @param callable(EventParticipant): bool $matches
*/
private function onlyOne(Collection $candidates, callable $matches): ?EventParticipant
{
$hits = $candidates->filter($matches);
return $hits->count() === 1 ? $hits->first() : null;
}
/** Noch offener Beitrag. `clone`, weil Amount seine Rechenoperationen auf sich selbst ausführt. */
private function amountLeft(EventParticipant $participant): Amount
{
$amountLeft = clone $participant->amount;
if ($participant->amount_paid !== null) {
$amountLeft->subtractAmount($participant->amount_paid);
}
return $amountLeft;
}
/** Kleinschreibung, Umlaute aufgelöst, alles außer [a-z0-9] raus. */
private function normalize(string $value): string
{
$value = mb_strtolower(trim($value));
$value = strtr($value, [
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss',
'á' => 'a', 'à' => 'a', 'â' => 'a', 'é' => 'e', 'è' => 'e', 'ê' => 'e',
'í' => 'i', 'ì' => 'i', 'ó' => 'o', 'ò' => 'o', 'ô' => 'o', 'ú' => 'u', 'ù' => 'u',
'ç' => 'c', 'ñ' => 'n',
]);
return preg_replace('/[^a-z0-9]/', '', $value) ?? '';
}
public function registrationSummary(RegistrationSummaryRequest $request): RegistrationSummaryResponse
{
$participant = $request->participant;
$config = $request->configuration;
$amountLeft = clone $participant->amount;
if ($participant->amount_paid !== null) {
$amountLeft->subtractAmount($participant->amount_paid);
}
$amountLeft = $this->amountLeft($participant);
$hasPaymentInformation = $amountLeft->getAmount() > 0;
@@ -85,10 +313,7 @@ class AccountTransferPaymentModule extends AbstractEventPaymentModule implements
public function giroCode(EventParticipant $participant, array $configuration): ?string
{
$amountLeft = clone $participant->amount;
if ($participant->amount_paid !== null) {
$amountLeft->subtractAmount($participant->amount_paid);
}
$amountLeft = $this->amountLeft($participant);
if ($amountLeft->getAmount() <= 0) {
return null;
@@ -0,0 +1,25 @@
<?php
namespace App\EventPaymentModules;
use App\ValueObjects\BankStatementRuleset;
/**
* Fähigkeit: Dieses Zahlungsmodul pflegt das Format des Kontoauszugs (CSV-Export der Bank).
*
* Bewusst getrennt von {@see ReadsBankStatements}: Das CSV-Format ist eine Eigenschaft der Bank, nicht
* der Zahlungsart. Eine Bank, ein Export, ein Ruleset -- deshalb implementiert es genau **ein** Modul
* (die Überweisung), und ein künftiges Lastschrift-Modul liest denselben Auszug, ohne dasselbe Format
* ein zweites Mal pflegen zu lassen. Sonst wäre nach dem nächsten Bankwechsel eine der beiden Stellen
* vergessen.
*
* Wie überall in dieser Schicht wird die Konfiguration **hereingereicht**, nicht selbst geholt: die
* Module bleiben frei von Datenbankzugriffen und damit ohne DB testbar.
*/
interface ProvidesStatementRuleset
{
/**
* @param array<string, mixed> $configuration Tenant-Konfiguration dieses Moduls
*/
public function statementRuleset(array $configuration): BankStatementRuleset;
}
@@ -0,0 +1,42 @@
<?php
namespace App\EventPaymentModules;
use App\EventPaymentModules\DTO\TransactionMatch;
use App\Models\EventParticipant;
use App\ValueObjects\BankTransaction;
use Illuminate\Support\Collection;
/**
* Fähigkeit: Dieses Zahlungsmodul kann Umsätze eines Kontoauszugs verwerten.
*
* Hier stecken die drei Entscheidungen, die von der Zahlungsart abhängen -- und nur diese:
* was zählt, wem es gehört, was nachgetragen wird. Der Ablauf drumherum (Upload, Parsen, Kandidaten
* laden, Wasserzeichen, `amount_paid` fortschreiben, Mail) ist zahlartneutral und liegt in der Action.
*
* Die Überweisung erkennt Gutschriften am Verwendungszweck; eine SEPA-Lastschrift wird später
* Belastungen und Rücklastschriften über die Mandatsreferenz erkennen -- gleiche Datei, andere Regeln.
*/
interface ReadsBankStatements
{
/** Interessiert dieser Umsatz diese Zahlungsart? */
public function isRelevantTransaction(BankTransaction $transaction): bool;
/**
* Zu welcher Anmeldung gehört der Umsatz?
*
* `null`, wenn keine oder mehrere passen -- ein unsicherer Vorschlag wäre schlimmer als keiner,
* weil er in der Prüfansicht bloß durchgewinkt würde.
*
* @param Collection<int, EventParticipant> $candidates fertig geladen; hier wird nichts abgefragt
*/
public function matchTransaction(BankTransaction $transaction, Collection $candidates): ?TransactionMatch;
/**
* Trägt die zahlartspezifischen Spuren des Umsatzes an der Anmeldung ein.
*
* Setzt nur die Attribute, **speichert nicht** -- gespeichert wird einmal am Ende durch die Action,
* zusammen mit dem Betrag.
*/
public function recordTransaction(EventParticipant $participant, BankTransaction $transaction): void;
}