Automatischer Zahlungsparser
This commit is contained in:
@@ -7,6 +7,9 @@ use App\EventPaymentModules\DTO\CreateInvoiceRequest;
|
||||
use App\EventPaymentModules\DTO\CreateInvoiceResponse;
|
||||
use App\EventPaymentModules\DTO\DoPaymentRequest;
|
||||
use App\EventPaymentModules\DTO\DoPaymentResponse;
|
||||
use App\Enumerations\RefundAccountSource;
|
||||
use App\EventPaymentModules\DTO\GetRefundDataRequest;
|
||||
use App\EventPaymentModules\DTO\GetRefundDataResponse;
|
||||
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
|
||||
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
|
||||
|
||||
@@ -192,6 +195,19 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule
|
||||
return new RegistrationSummaryResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard: Es gab ein Ursprungskonto, wir kennen es nur nicht ({@see RefundAccountSource::Origin}).
|
||||
*
|
||||
* Bewusst die strengere Annahme. Der Teili wird dann gefragt, ob es dasselbe Konto ist, von dem der
|
||||
* Beitrag kam -- die Kontrolle, die verhindert, dass sich über eine Erstattung Geld auf ein fremdes
|
||||
* Konto umleiten lässt. Eine Zahlungsart ohne Ursprungskonto (Barzahlung) muss das ausdrücklich
|
||||
* sagen; stillschweigend die Kontrolle fallen zu lassen wäre die falsche Vorgabe.
|
||||
*/
|
||||
public function getRefundData(GetRefundDataRequest $request): GetRefundDataResponse
|
||||
{
|
||||
return new GetRefundDataResponse();
|
||||
}
|
||||
|
||||
public function doPayment(DoPaymentRequest $request): DoPaymentResponse
|
||||
{
|
||||
// TODO: In einer Folge-Iteration ausformulieren. Default: nichts aktiv anzustoßen (Status offen).
|
||||
|
||||
@@ -7,12 +7,13 @@ Rechnung) liegt gekapselt in einem Modul pro Zahlungsart. Aufgelöst wird über
|
||||
|
||||
- `EventPaymentModule` — **Core-Interface**. Hält nur das, was **jede** Zahlungsart hat:
|
||||
`slug()`, `defaultName()/defaultDescription()`, `getOptions()`, `registrationSummary()`, `doPayment()`,
|
||||
`createInvoice()`.
|
||||
`createInvoice()`, `getRefundData()`.
|
||||
- `AbstractEventPaymentModule` — Basisklasse (Template-Method). Liefert die aus `getOptions()` abgeleiteten Helfer
|
||||
(`requiredOptionKeys()`, `sanitizeConfiguration()`, `isConfigurationComplete()`) und sinnvolle Default-/Stub-Bodies.
|
||||
- `Modules/` — konkrete Module (flach, eine Klasse je Zahlungsart):
|
||||
`AccountTransferPaymentModule` (Überweisung), `UndefinedPaymentModule` (Barzahlung/Sonstiges).
|
||||
- `DTO/` — geteilte Request/Response-DTOs je Operation (`DoPayment*`, `CreateInvoice*`, `RegistrationSummary*`).
|
||||
- `DTO/` — geteilte Request/Response-DTOs je Operation (`DoPayment*`, `CreateInvoice*`, `RegistrationSummary*`,
|
||||
`GetRefundData*`, `TransactionMatch`).
|
||||
- `EventPaymentModuleRegistry` — statische Map `slug → Modul-Instanz` (`forSlug()`, `all()`, `slugs()`). **Neue Module
|
||||
hier eintragen.** Kein Container-Binding.
|
||||
- `ProvidesGiroCode`, `ProvidesStatementRuleset`, `ReadsBankStatements` — **Fähigkeits-Interfaces** (siehe unten).
|
||||
@@ -64,6 +65,32 @@ Rechnung) liegt gekapselt in einem Modul pro Zahlungsart. Aufgelöst wird über
|
||||
gehören NICHT ins Status-Vokabular, sondern als transiente Felder aufs Response-DTO.
|
||||
- `doPayment()` und `createInvoice()` sind aktuell **Stubs** (nur Struktur/DTOs vorhanden). `createInvoice()` ist als
|
||||
Template-Method angelegt: gemeinsamer Rumpf in der Basis, `invoiceClosingStatement()` je Modul.
|
||||
- **`getRefundData()` — „auf welches Konto wäre zu erstatten, und woher kommt es?"** Steht im **Kern-Interface**, weil
|
||||
jede Zahlungsart eine Antwort darauf hat; sie fällt nur unterschiedlich aus. Geantwortet wird mit
|
||||
`App\Enumerations\RefundAccountSource` (reines Code-Enum, nirgends gespeichert):
|
||||
- `Known` — das Konto liegt vor. Nur die Überweisung liefert das, aus `payment_options`
|
||||
(`payer_iban`/`payer_account_owner`, vom Kontoauszug-Import hinterlegt) und **nur bei gültiger Prüfziffer**; eine
|
||||
ungültige IBAN würde ungeprüft übernommen. `event_participants.refund_data` ist demgegenüber nur ein
|
||||
**abgeleitetes Kennzeichen** für Listen und Abfragen, nie die Quelle — zwei Quellen für dieselbe Wahrheit driften
|
||||
auseinander.
|
||||
- `Origin` — es gab ein Ursprungskonto, wir kennen es nicht. **Vorgabe der Basisklasse**, bewusst die strengere
|
||||
Annahme: Der Teili wird gefragt, ob es dasselbe Konto ist, und bestätigt die Herkunft. Das ist die Kontrolle
|
||||
gegen das Umleiten einer Erstattung auf ein fremdes Konto; sie stillschweigend fallen zu lassen wäre die falsche
|
||||
Vorgabe für ein künftiges Modul.
|
||||
- `None` — es gab **nie** eines (`UndefinedPaymentModule`, Barzahlung). Herkunftsfrage und Herkunfts-Erklärung
|
||||
wären sinnlos bzw. unwahr; an ihre Stelle tritt `CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT_OWN` („läuft auf meinen
|
||||
Namen"). Welcher `page_texts`-Eintrag gilt, sagt `RefundAccountSource::accountDeclarationText()` — eine Quelle
|
||||
für Seite **und** Beleg.
|
||||
Aufgelöst wird überall über `EventParticipant::refundData()`; verwertet in `ReleaseRefundCommand` (schreibt ein
|
||||
bekanntes Konto direkt an den Vorgang, der aber `pending` bleibt — der Teili entscheidet noch über Auszahlung oder
|
||||
Spende), in `AcceptRefundCommand` (ein gesetztes Konto lässt sich **nicht** aus dem Request überschreiben), im
|
||||
`RefundPageController` und im Erstattungsbeleg. Auf der Token-Seite und in der Freigabe-Mail geht eine bekannte IBAN
|
||||
nur maskiert hinaus (`Iban::mask()`).
|
||||
- **Keine Barauszahlung.** Auch wer bar gezahlt hat, bekommt überwiesen. Rechtlich spricht nichts dagegen — das GwG
|
||||
gilt für den Verband nicht (§ 2 Abs. 1 GwG; kein Güterhändler nach § 1 Abs. 9), und eine Regel „bar rein, bar raus"
|
||||
existiert nicht. Die Überweisung ist zudem besser belegt: Der Kontoauszug beweist die Zahlung, während eine
|
||||
Barauszahlung an einer Unterschrift hinge und die Barkasse nach § 146 AO kassensturzfähig zu halten wäre. Wer doch
|
||||
bar auszahlt, bucht das über die normale Auslagenerfassung.
|
||||
|
||||
## Zahlart-spezifisches Verhalten → Fähigkeits-Interfaces (Interface Segregation)
|
||||
|
||||
@@ -153,8 +180,9 @@ Live-Inbetriebnahme einmal gegen die Produktionsdatenbank ausführen — ersetzt
|
||||
|
||||
`tests/Unit/PaymentMethodOptionsTest`, `tests/Unit/EventPaymentModuleRegistryTest`,
|
||||
`tests/Unit/RegistrationSummaryTest`, `tests/Unit/BankStatementParseTest`, `tests/Unit/BankStatementMatchTest`,
|
||||
`tests/Unit/BankStatementRulesetTest`, `tests/Feature/PaymentMethodConfigurationTest`,
|
||||
`tests/Feature/EventParticipantPaymentSummaryTest`, `tests/Feature/BankStatementImportTest`.
|
||||
`tests/Unit/BankStatementRulesetTest`, `tests/Unit/RefundDataTest`, `tests/Feature/PaymentMethodConfigurationTest`,
|
||||
`tests/Feature/EventParticipantPaymentSummaryTest`, `tests/Feature/BankStatementImportTest`,
|
||||
`tests/Feature/RefundKnownAccountTest`, `tests/Feature/RefundCashPayerTest`.
|
||||
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventPaymentModules\DTO;
|
||||
|
||||
use App\Models\EventParticipant;
|
||||
|
||||
/**
|
||||
* Eingabe für {@see \App\EventPaymentModules\EventPaymentModule::getRefundData()}.
|
||||
*
|
||||
* Wie überall in dieser Schicht wird die Konfiguration hereingereicht, statt sie selbst zu holen --
|
||||
* die Module bleiben damit frei von Datenbankzugriffen und ohne DB testbar.
|
||||
*/
|
||||
final class GetRefundDataRequest
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $configuration aufgelöste Konfiguration des Zahlungsmoduls
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly EventParticipant $participant,
|
||||
public readonly array $configuration = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventPaymentModules\DTO;
|
||||
|
||||
use App\Enumerations\RefundAccountSource;
|
||||
|
||||
/**
|
||||
* Auf welches Konto ist zu erstatten -- und woher kommt es?
|
||||
*
|
||||
* `source` ist die eigentliche Auskunft: Kennen wir das Konto bereits (`Known`), gab es eines, das wir
|
||||
* erfragen müssen (`Origin`), oder gab es nie eines (`None`, Barzahlung)? Davon hängt ab, was die
|
||||
* Erstattungsseite fragt und welche Erklärung der Teili unterschreibt.
|
||||
*
|
||||
* Konto und Inhaber sind nur bei `Known` gefüllt -- und dann auch nur, wenn beide vorliegen und die
|
||||
* IBAN die Prüfziffer besteht. Eine ungültige IBAN würde ungeprüft übernommen und das Geld ginge im
|
||||
* Zweifel an eine fremde Person; lieber wie bisher nachfragen.
|
||||
*/
|
||||
final class GetRefundDataResponse
|
||||
{
|
||||
public RefundAccountSource $source = RefundAccountSource::Origin;
|
||||
|
||||
public ?string $accountOwner = null;
|
||||
public ?string $accountIban = null;
|
||||
|
||||
/** Woher die Angaben stammen -- Klartext für die Anzeige in der Aktionsleitung. */
|
||||
public ?string $sourceNote = null;
|
||||
|
||||
/** Kurzform für „das Konto steht fest": nur dann sind accountOwner/accountIban gefüllt. */
|
||||
public function hasAccount(): bool
|
||||
{
|
||||
return $this->source === RefundAccountSource::Known;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ use App\EventPaymentModules\DTO\CreateInvoiceRequest;
|
||||
use App\EventPaymentModules\DTO\CreateInvoiceResponse;
|
||||
use App\EventPaymentModules\DTO\DoPaymentRequest;
|
||||
use App\EventPaymentModules\DTO\DoPaymentResponse;
|
||||
use App\EventPaymentModules\DTO\GetRefundDataRequest;
|
||||
use App\EventPaymentModules\DTO\GetRefundDataResponse;
|
||||
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
|
||||
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
|
||||
|
||||
@@ -68,4 +70,16 @@ interface EventPaymentModule
|
||||
* (In dieser Iteration nur als Stub vorhanden.)
|
||||
*/
|
||||
public function createInvoice(CreateInvoiceRequest $request): CreateInvoiceResponse;
|
||||
|
||||
/**
|
||||
* Auf welches Konto wäre zu erstatten -- und wissen wir es überhaupt?
|
||||
*
|
||||
* Gehört ins Kern-Interface und nicht in ein Fähigkeits-Interface, weil jede Zahlungsart eine
|
||||
* Antwort darauf hat; sie fällt nur unterschiedlich aus. Barzahlung: keine. Überweisung: das
|
||||
* Konto, von dem der Beitrag kam. SEPA-Lastschrift später: das Konto des Mandats.
|
||||
*
|
||||
* Standard ist „nicht bekannt" -- dann läuft die Erstattung wie gehabt über die Angaben des
|
||||
* Teilis bzw. der Aktionsleitung.
|
||||
*/
|
||||
public function getRefundData(GetRefundDataRequest $request): GetRefundDataResponse;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
namespace App\EventPaymentModules\Modules;
|
||||
|
||||
use App\Enumerations\RefundAccountSource;
|
||||
use App\EventPaymentModules\AbstractEventPaymentModule;
|
||||
use App\EventPaymentModules\DTO\CreateInvoiceRequest;
|
||||
use App\EventPaymentModules\DTO\GetRefundDataRequest;
|
||||
use App\EventPaymentModules\DTO\GetRefundDataResponse;
|
||||
use App\EventPaymentModules\DTO\RegistrationRenderContext;
|
||||
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
|
||||
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
|
||||
@@ -108,6 +111,41 @@ class AccountTransferPaymentModule extends AbstractEventPaymentModule implements
|
||||
return BankStatementRuleset::fromConfiguration(is_array($override) ? $override : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstattet wird auf das Konto, von dem der Beitrag kam -- und genau das steht seit dem
|
||||
* Zahlungsimport in den Teilnehmer-Optionen.
|
||||
*
|
||||
* Gelesen wird ausschließlich aus `payment_options`, nicht aus dem Kennzeichen `refund_data`:
|
||||
* Das ist ein abgeleitetes Merkmal für Listen und Abfragen. Zwei Quellen für dieselbe Wahrheit
|
||||
* driften früher oder später auseinander, und die falsche gewänne dann eine Auszahlung.
|
||||
*
|
||||
* Eine IBAN, die die Prüfziffer nicht besteht, wird nicht gemeldet: Sie würde ungeprüft
|
||||
* übernommen und das Geld ginge im Zweifel an eine fremde Person. Lieber wie bisher nachfragen.
|
||||
*/
|
||||
public function getRefundData(GetRefundDataRequest $request): GetRefundDataResponse
|
||||
{
|
||||
$response = new GetRefundDataResponse();
|
||||
|
||||
$options = $request->participant->payment_options ?? [];
|
||||
$iban = Iban::normalize((string) ($options[self::OPTION_PAYER_IBAN] ?? ''));
|
||||
$owner = trim((string) ($options[self::OPTION_PAYER_ACCOUNT_OWNER] ?? ''));
|
||||
|
||||
if ($iban === '' || $owner === '' || !Iban::isValid($iban)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$response->source = RefundAccountSource::Known;
|
||||
$response->accountOwner = $owner;
|
||||
$response->accountIban = $iban;
|
||||
|
||||
$paidOn = $request->participant->last_payment_date?->format('d.m.Y');
|
||||
$response->sourceNote = $paidOn === null
|
||||
? 'Zahlungseingang'
|
||||
: 'Zahlungseingang vom ' . $paidOn;
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/** Bei der Überweisung zählen Gutschriften -- Belastungen sind Ausgaben der Aktion. */
|
||||
public function isRelevantTransaction(BankTransaction $transaction): bool
|
||||
{
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
namespace App\EventPaymentModules\Modules;
|
||||
|
||||
use App\Enumerations\RefundAccountSource;
|
||||
use App\EventPaymentModules\AbstractEventPaymentModule;
|
||||
use App\EventPaymentModules\DTO\CreateInvoiceRequest;
|
||||
use App\EventPaymentModules\DTO\GetRefundDataRequest;
|
||||
use App\EventPaymentModules\DTO\GetRefundDataResponse;
|
||||
use App\EventPaymentModules\DTO\RegistrationRenderContext;
|
||||
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
|
||||
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
|
||||
@@ -57,6 +60,25 @@ class UndefinedPaymentModule extends AbstractEventPaymentModule
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bar gezahlt heißt: Es gab nie ein Konto, von dem der Beitrag kam.
|
||||
*
|
||||
* Damit greift die sonst geltende Kontrolle „zurück nur auf das Ursprungskonto" nicht -- die Frage
|
||||
* danach wäre für den Teili sinnlos und die Erklärung, es sei dasselbe Konto, schlicht unwahr. An
|
||||
* ihre Stelle tritt die Erklärung, dass das angegebene Konto auf seinen Namen läuft.
|
||||
*
|
||||
* Zurückgezahlt wird trotzdem per Überweisung: Der Kontoauszug belegt die Zahlung, während eine
|
||||
* Barauszahlung an einer Unterschrift hinge und die Barkasse berührte. Rechtlich spricht nichts
|
||||
* dagegen -- eine Regel „bar rein, bar raus" gibt es nicht.
|
||||
*/
|
||||
public function getRefundData(GetRefundDataRequest $request): GetRefundDataResponse
|
||||
{
|
||||
$response = new GetRefundDataResponse();
|
||||
$response->source = RefundAccountSource::None;
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function invoiceClosingStatement(CreateInvoiceRequest $request): string
|
||||
{
|
||||
return sprintf(
|
||||
|
||||
Reference in New Issue
Block a user