355 lines
14 KiB
PHP
355 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\EventPaymentModules\Modules;
|
|
|
|
use App\EventPaymentModules\AbstractEventPaymentModule;
|
|
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,
|
|
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;
|
|
}
|
|
|
|
public function defaultName(): string
|
|
{
|
|
return 'Überweisung auf Veranstaltungskonto';
|
|
}
|
|
|
|
public function defaultConfiguration(): array
|
|
{
|
|
return ['icon' => 'building-columns'];
|
|
}
|
|
|
|
public function getOptions(): array
|
|
{
|
|
return [
|
|
['name' => 'account_owner', 'label' => 'Kontoinhaber', 'type' => 'string', 'required' => true],
|
|
['name' => 'iban', 'label' => 'IBAN', 'type' => 'string', 'required' => true],
|
|
['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 = $this->amountLeft($participant);
|
|
|
|
$hasPaymentInformation = $amountLeft->getAmount() > 0;
|
|
|
|
// GiroCode nur bei offenem Betrag; Bildquelle je Render-Kontext (Web-Endpoint vs. Mail-CID).
|
|
$giroCodeSrc = null;
|
|
if ($hasPaymentInformation) {
|
|
$giroCodeSrc = $request->context === RegistrationRenderContext::Mail
|
|
? 'cid:girocode.png'
|
|
: '/print-girocode/' . $participant->identifier;
|
|
}
|
|
|
|
// Medien-gerechtes Template je Render-Kontext (Web: SPA-Klassen, Mail: E-Mail-tauglich).
|
|
$view = $request->context === RegistrationRenderContext::Mail
|
|
? 'payment-modules.account-transfer.mail'
|
|
: 'payment-modules.account-transfer.web';
|
|
|
|
$response = new RegistrationSummaryResponse();
|
|
$response->hasPaymentInformation = $hasPaymentInformation;
|
|
$response->html = view($view, [
|
|
'accountOwner' => (string)($config['account_owner'] ?? ''),
|
|
'iban' => (string)($config['iban'] ?? ''),
|
|
'paymentPurpose' => (string)$participant->payment_purpose,
|
|
'amount' => $amountLeft->toString(),
|
|
'giroCodeSrc' => $giroCodeSrc,
|
|
'reminderUrl' => url('/api/v1/event/participant/' . $participant->identifier . '/ical-payment'),
|
|
'paymentDeadline' => $participant->event?->registration_final_end?->format('d.m.Y'),
|
|
])->render();
|
|
|
|
return $response;
|
|
}
|
|
|
|
public function giroCode(EventParticipant $participant, array $configuration): ?string
|
|
{
|
|
$amountLeft = $this->amountLeft($participant);
|
|
|
|
if ($amountLeft->getAmount() <= 0) {
|
|
return null;
|
|
}
|
|
|
|
$provider = new GiroCodeProvider(
|
|
(string)($configuration['account_owner'] ?? ''),
|
|
(string)($configuration['iban'] ?? ''),
|
|
$amountLeft->getAmount(),
|
|
(string)$participant->payment_purpose,
|
|
);
|
|
|
|
return (string)$provider->create();
|
|
}
|
|
|
|
protected function invoiceClosingStatement(CreateInvoiceRequest $request): string
|
|
{
|
|
$config = $request->configuration;
|
|
$deadline = $request->event->registration_final_end?->format('d.m.Y');
|
|
|
|
$sentence = $deadline !== null
|
|
? sprintf('Bitte überweise %s bis zum %s auf folgendes Konto:', $request->amount->toString(), $deadline)
|
|
: sprintf('Bitte überweise %s auf folgendes Konto:', $request->amount->toString());
|
|
|
|
$lines = [
|
|
'Kontoinhaber*in: ' . (string) ($config['account_owner'] ?? ''),
|
|
'IBAN: ' . (string) ($config['iban'] ?? ''),
|
|
];
|
|
|
|
if (($config['bic'] ?? '') !== '') {
|
|
$lines[] = 'BIC: ' . (string) $config['bic'];
|
|
}
|
|
|
|
$lines[] = 'Verwendungszweck: ' . (string) $request->participant->payment_purpose;
|
|
|
|
return $sentence . '<br />' . implode('<br />', array_map(e(...), $lines));
|
|
}
|
|
}
|