Zahlungsparser
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user