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
@@ -0,0 +1,142 @@
<?php
namespace App\Domains\Event\Actions\BookBankStatementPayments;
use App\Domains\Event\Actions\ParticipantPayment\ParticipantPaymentCommand;
use App\Domains\Event\Actions\ParticipantPayment\ParticipantPaymentRequest;
use App\EventPaymentModules\EventPaymentModuleRegistry;
use App\EventPaymentModules\ReadsBankStatements;
use App\Models\EventParticipant;
use App\Models\PaymentMethod;
use App\Repositories\EventParticipantRepository;
use App\ValueObjects\Amount;
use App\ValueObjects\BankTransaction;
use Carbon\CarbonImmutable;
/**
* Bucht die in der Prüfansicht bestätigten Zahlungseingänge -- alle in einem Durchgang.
*
* Bewusst **ohne** umspannende Transaktion: Gebucht wird über {@see ParticipantPaymentCommand}, und der
* verschickt die Zahlungsmail gleich mit. Das Projekt kennt keine Queue, die Mails gehen also synchron
* raus; eine offene Transaktion über achtzig Mailversände wäre die schlechtere Wahl. Jede Zeile ist
* für sich vollständig, und das Wasserzeichen schützt sie gegen eine Wiederholung.
*/
class BookBankStatementPaymentsCommand
{
public function __construct(private readonly BookBankStatementPaymentsRequest $request)
{
}
public function execute(): BookBankStatementPaymentsResponse
{
$response = new BookBankStatementPaymentsResponse();
if ($this->request->bookings === []) {
$response->message = 'Es wurden keine Zahlungen zum Buchen ausgewählt.';
return $response;
}
$module = EventPaymentModuleRegistry::forSlug(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION);
$participants = new EventParticipantRepository();
foreach ($this->request->bookings as $booking) {
$participant = $participants->findInEventByIdentifier(
$this->request->event,
(string) ($booking['participantIdentifier'] ?? ''),
);
$transaction = $this->toTransaction($booking);
if ($participant === null || $transaction === null) {
$response->failed++;
continue;
}
// Zweite Prüfung, nicht bloß Gürtel und Hosenträger: In der Prüfansicht lässt sich eine
// Zeile von Hand einer anderen Person zuordnen, und deren Wasserzeichen hat der erste
// Durchlauf nie gesehen.
if ($this->isBeforeWatermark($participant, $transaction)) {
$response->skipped++;
continue;
}
if ($module instanceof ReadsBankStatements) {
$module->recordTransaction($participant, $transaction);
}
$participant->last_payment_date = $transaction->paymentDate;
$this->book($participant, $transaction->amount);
$response->booked++;
}
$response->success = true;
$response->message = $this->summaryMessage($response);
return $response;
}
/**
* Bucht den Betrag **zusätzlich** zum bereits Gezahlten.
*
* Die manuelle Erfassung setzt `amount_paid` absolut, weil dort jemand den Gesamtstand eintippt.
* Hier kommt eine einzelne Zahlung an -- eine zweite Rate darf die erste nicht überschreiben.
* Der {@see ParticipantPaymentCommand} speichert dabei die zuvor gesetzten Felder mit und schickt
* die passende Mail (bezahlt / fehlt noch / überzahlt).
*/
private function book(EventParticipant $participant, Amount $amount): void
{
$total = new Amount(
round(($participant->amount_paid?->getAmount() ?? 0.0) + $amount->getAmount(), 2),
'Euro',
);
new ParticipantPaymentCommand(new ParticipantPaymentRequest($participant, $total))->execute();
}
/** @param array<string, mixed> $booking */
private function toTransaction(array $booking): ?BankTransaction
{
$date = CarbonImmutable::createFromFormat('!Y-m-d', (string) ($booking['paymentDate'] ?? ''));
$amount = $booking['amount'] ?? null;
if ($date === false || !is_numeric($amount) || (float) $amount <= 0) {
return null;
}
return new BankTransaction(
paymentDate: $date,
amount: new Amount(round((float) $amount, 2), 'Euro'),
purpose: (string) ($booking['purpose'] ?? ''),
payerName: (string) ($booking['payerName'] ?? ''),
payerIban: (string) ($booking['payerIban'] ?? ''),
rowNumber: (int) ($booking['rowNumber'] ?? 0),
);
}
private function isBeforeWatermark(EventParticipant $participant, BankTransaction $transaction): bool
{
$watermark = $participant->last_payment_date;
return $watermark !== null && $transaction->paymentDate->startOfDay()->lt($watermark->startOfDay());
}
private function summaryMessage(BookBankStatementPaymentsResponse $response): string
{
$message = sprintf('%d Zahlungen wurden gebucht.', $response->booked);
if ($response->skipped > 0) {
$message .= sprintf(
' %d wurden übersprungen, weil sie älter sind als die zuletzt erfasste Zahlung der Person.',
$response->skipped,
);
}
if ($response->failed > 0) {
$message .= sprintf(' %d konnten nicht zugeordnet werden.', $response->failed);
}
return $message;
}
}