Dev 4.9.0 #17
@@ -5,6 +5,7 @@ namespace App\Domains\Admin\Controllers;
|
||||
use App\Models\AvailablePaymentMethod;
|
||||
use App\Resources\AvailablePaymentMethodResource;
|
||||
use App\Scopes\CommonController;
|
||||
use App\ValueObjects\BankStatementRuleset;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -16,6 +17,10 @@ class TenantPaymentMethodsGetController extends CommonController
|
||||
'paymentMethods' => AvailablePaymentMethod::all()
|
||||
->map(fn (AvailablePaymentMethod $method) => new AvailablePaymentMethodResource($method)->toArray($request)),
|
||||
'saveEndpoint' => '/api/v1/admin/tenant/payment-methods',
|
||||
// Der app-weite Standard für das Kontoauszug-Format. Das Formular zeigt ihn als
|
||||
// Vorbelegung an, damit erkennbar ist, wovon ein Override abweicht.
|
||||
'statementRulesetDefault' => BankStatementRuleset::default()->toArray(),
|
||||
'statementRulesetCharsets' => BankStatementRuleset::CHARSETS,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import {computed, ref} from 'vue';
|
||||
import BankRulesetEditor from "../../../../Views/Components/BankRulesetEditor.vue";
|
||||
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
|
||||
import TextEditor from "../../../../Views/Components/TextEditor.vue";
|
||||
import RichSelectBox from "../../../../Views/Components/RichSelectBox.vue";
|
||||
@@ -102,6 +103,10 @@ async function save() {
|
||||
<RichSelectBox v-if="option.type === 'icon'" v-model="form.configuration[option.name]"
|
||||
:options="PAYMENT_METHOD_ICONS" placeholder="Symbol wählen…"/>
|
||||
<TextEditor v-else-if="option.type === 'richtext'" v-model="form.configuration[option.name]"/>
|
||||
<BankRulesetEditor v-else-if="option.type === 'bank-ruleset'"
|
||||
v-model="form.configuration[option.name]"
|
||||
:defaults="data.statementRulesetDefault ?? {}"
|
||||
:charsets="data.statementRulesetCharsets ?? undefined"/>
|
||||
<input v-else type="text" v-model="form.configuration[option.name]" class="form-input"/>
|
||||
<span v-if="option.hint" class="option-hint">{{ option.hint }}</span>
|
||||
</td>
|
||||
|
||||
+142
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\BookBankStatementPayments;
|
||||
|
||||
use App\Models\Event;
|
||||
|
||||
class BookBankStatementPaymentsRequest
|
||||
{
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $bookings Je Eintrag die bestätigte Zeile der Prüfansicht:
|
||||
* participantIdentifier, paymentDate, amount,
|
||||
* payerName, payerIban, purpose.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Event $event,
|
||||
public readonly array $bookings,
|
||||
) {
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\BookBankStatementPayments;
|
||||
|
||||
class BookBankStatementPaymentsResponse
|
||||
{
|
||||
public bool $success = false;
|
||||
public string $message = '';
|
||||
|
||||
public int $booked = 0;
|
||||
|
||||
/** Übersprungen, weil älter als die zuletzt erfasste Zahlung der Person. */
|
||||
public int $skipped = 0;
|
||||
|
||||
/** Nicht verarbeitbar (unbekannte Anmeldung, unbrauchbarer Betrag). */
|
||||
public int $failed = 0;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\ParseBankStatement;
|
||||
|
||||
use App\EventPaymentModules\EventPaymentModuleRegistry;
|
||||
use App\EventPaymentModules\ProvidesStatementRuleset;
|
||||
use App\EventPaymentModules\ReadsBankStatements;
|
||||
use App\Models\EventParticipant;
|
||||
use App\Models\PaymentMethod;
|
||||
use App\Providers\BankStatementParseProvider;
|
||||
use App\Repositories\EventParticipantRepository;
|
||||
use App\Support\BankStatementParseException;
|
||||
use App\ValueObjects\BankStatementRuleset;
|
||||
use App\ValueObjects\BankTransaction;
|
||||
|
||||
/**
|
||||
* Liest einen hochgeladenen Kontoauszug und schlägt je Zahlungseingang eine Anmeldung vor.
|
||||
*
|
||||
* Hier wird nichts gebucht -- das Ergebnis ist die Prüfansicht, in der die Aktionsleitung die
|
||||
* Vorschläge bestätigt, korrigiert oder verwirft. Erst der zweite Schritt schreibt.
|
||||
*
|
||||
* Der Ablauf ist zahlartneutral: Was ein verwertbarer Umsatz ist und zu wem er gehört, entscheidet
|
||||
* das Zahlungsmodul über {@see ReadsBankStatements}.
|
||||
*/
|
||||
class ParseBankStatementCommand
|
||||
{
|
||||
/** 8 MB -- ein Jahresauszug bleibt weit darunter, alles darüber ist die falsche Datei. */
|
||||
private const int MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
private const array ALLOWED_EXTENSIONS = ['csv', 'txt'];
|
||||
|
||||
public function __construct(private readonly ParseBankStatementRequest $request)
|
||||
{
|
||||
}
|
||||
|
||||
public function execute(): ParseBankStatementResponse
|
||||
{
|
||||
$response = new ParseBankStatementResponse();
|
||||
|
||||
$module = EventPaymentModuleRegistry::forSlug(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION);
|
||||
if (!$module instanceof ReadsBankStatements) {
|
||||
$response->message = 'Für die Überweisung ist kein Kontoauszug-Import eingerichtet.';
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
$contents = $this->readFile($response);
|
||||
if ($contents === null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$ruleset = $module instanceof ProvidesStatementRuleset
|
||||
? $module->statementRuleset($this->request->configuration)
|
||||
: BankStatementRuleset::default();
|
||||
|
||||
try {
|
||||
$transactions = new BankStatementParseProvider()->parse($contents, $ruleset);
|
||||
} catch (BankStatementParseException $exception) {
|
||||
$response->message = $exception->getMessage();
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
$candidates = new EventParticipantRepository()->getForPaymentMatching($this->request->event);
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
if (!$module->isRelevantTransaction($transaction)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$match = $module->matchTransaction($transaction, $candidates);
|
||||
|
||||
// Vor der letzten erfassten Zahlung dieser Person: schon gebucht, oder aus einem Auszug,
|
||||
// der bereits verarbeitet wurde. Gar nicht erst anzeigen.
|
||||
if ($match !== null && $this->isBeforeWatermark($match->participant, $transaction)) {
|
||||
$response->skippedOlderThanWatermark++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$response->rows[] = $transaction->toArray() + [
|
||||
'suggestedIdentifier' => $match?->participant->identifier,
|
||||
'confidence' => $match?->confidence,
|
||||
];
|
||||
}
|
||||
|
||||
$response->participants = $candidates
|
||||
->map(fn (EventParticipant $participant): array => $this->participantOption($participant))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$response->success = true;
|
||||
$response->message = $this->summaryMessage($response);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function readFile(ParseBankStatementResponse $response): ?string
|
||||
{
|
||||
$file = $this->request->file;
|
||||
|
||||
if ($file === null || !$file->isValid()) {
|
||||
$response->message = 'Es wurde keine Datei hochgeladen.';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($file->getSize() > self::MAX_BYTES) {
|
||||
$response->message = 'Die Datei ist zu groß (maximal 8 MB).';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!in_array(strtolower($file->getClientOriginalExtension()), self::ALLOWED_EXTENSIONS, true)) {
|
||||
$response->message = 'Bitte den CSV-Export der Bank hochladen (.csv).';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = file_get_contents($file->getRealPath());
|
||||
|
||||
if ($contents === false || trim($contents) === '') {
|
||||
$response->message = 'Die Datei ließ sich nicht lesen oder ist leer.';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $contents;
|
||||
}
|
||||
|
||||
private function isBeforeWatermark(EventParticipant $participant, BankTransaction $transaction): bool
|
||||
{
|
||||
$watermark = $participant->last_payment_date;
|
||||
|
||||
// Echt kleiner, nicht kleiner-gleich: Zwei Zahlungen am selben Tag sollen beide ankommen.
|
||||
return $watermark !== null && $transaction->paymentDate->startOfDay()->lt($watermark->startOfDay());
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function participantOption(EventParticipant $participant): array
|
||||
{
|
||||
$amountLeft = clone $participant->amount;
|
||||
if ($participant->amount_paid !== null) {
|
||||
$amountLeft->subtractAmount($participant->amount_paid);
|
||||
}
|
||||
|
||||
return [
|
||||
'identifier' => $participant->identifier,
|
||||
'name' => $participant->lastname . ', ' . $participant->firstname,
|
||||
'amount' => $participant->amount?->toString(),
|
||||
'amountPaid' => $participant->amount_paid?->toString(),
|
||||
'amountOpen' => $amountLeft->toString(),
|
||||
'isSettled' => round($amountLeft->getAmount(), 2) <= 0,
|
||||
'lastPaymentDate' => $participant->last_payment_date?->format('d.m.Y'),
|
||||
// Zahlt jemand, der sich abgemeldet hat, wird die Zahlung trotzdem erfasst -- danach
|
||||
// steht aber eine Erstattung an. Die Prüfansicht weist darauf hin.
|
||||
'isSignedOff' => $participant->unregistered_at !== null,
|
||||
'signedOffAt' => $participant->unregistered_at?->format('d.m.Y'),
|
||||
];
|
||||
}
|
||||
|
||||
private function summaryMessage(ParseBankStatementResponse $response): string
|
||||
{
|
||||
if ($response->rows === []) {
|
||||
return $response->skippedOlderThanWatermark > 0
|
||||
? 'Alle Zahlungseingänge dieser Datei wurden bereits erfasst.'
|
||||
: 'In der Datei sind keine Zahlungseingänge zu dieser Aktion enthalten.';
|
||||
}
|
||||
|
||||
$assigned = count(array_filter($response->rows, static fn (array $row): bool => $row['suggestedIdentifier'] !== null));
|
||||
|
||||
$message = sprintf(
|
||||
'%d Zahlungseingänge gelesen, %d davon konnten zugeordnet werden.',
|
||||
count($response->rows),
|
||||
$assigned,
|
||||
);
|
||||
|
||||
if ($response->skippedOlderThanWatermark > 0) {
|
||||
$message .= sprintf(' %d bereits erfasste Zahlungen wurden übersprungen.', $response->skippedOlderThanWatermark);
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\ParseBankStatement;
|
||||
|
||||
use App\Models\Event;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class ParseBankStatementRequest
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $configuration Tenant-Konfiguration der Überweisung. Wird hereingereicht
|
||||
* statt hier geholt -- den DB-Zugriff macht der Controller
|
||||
* über das Repository.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Event $event,
|
||||
public readonly ?UploadedFile $file,
|
||||
public readonly array $configuration = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\ParseBankStatement;
|
||||
|
||||
class ParseBankStatementResponse
|
||||
{
|
||||
public bool $success = false;
|
||||
public string $message = '';
|
||||
|
||||
/**
|
||||
* Eine Zeile je verwertbarem Umsatz, fertig für die Prüfansicht.
|
||||
*
|
||||
* @var array<int, array<string, mixed>>
|
||||
*/
|
||||
public array $rows = [];
|
||||
|
||||
/**
|
||||
* Alle zuordenbaren Anmeldungen der Aktion für die Auswahlliste.
|
||||
*
|
||||
* @var array<int, array<string, mixed>>
|
||||
*/
|
||||
public array $participants = [];
|
||||
|
||||
/**
|
||||
* Umsätze, die vor der zuletzt erfassten Zahlung der erkannten Person liegen. Sie erscheinen gar
|
||||
* nicht erst in der Prüfansicht -- gezählt werden sie trotzdem, sonst bliebe unerklärt, warum
|
||||
* eine Datei mit 40 Zeilen nur 12 Vorschläge ergibt.
|
||||
*/
|
||||
public int $skippedOlderThanWatermark = 0;
|
||||
}
|
||||
@@ -64,7 +64,7 @@ class SetPaymentMethodsCommand
|
||||
// Bestehende Zuweisung: Config nur bei explizitem Override anpassen, sonst unverändert lassen.
|
||||
if ($override !== null) {
|
||||
$row = $existing[$slug];
|
||||
$row->configuration = PaymentMethod::sanitizeConfiguration($slug, $override);
|
||||
$row->configuration = $this->eventConfiguration($slug, $override);
|
||||
$row->save();
|
||||
}
|
||||
continue;
|
||||
@@ -75,8 +75,27 @@ class SetPaymentMethodsCommand
|
||||
EventPaymentMethods::create([
|
||||
'event_id' => $event->id,
|
||||
'slug' => $slug,
|
||||
'configuration' => PaymentMethod::sanitizeConfiguration($slug, $snapshot ?? []),
|
||||
'configuration' => $this->eventConfiguration($slug, $snapshot ?? []),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Was von einer Konfiguration am Event gespeichert wird.
|
||||
*
|
||||
* Neben dem Zuschnitt aufs Schema fallen hier die tenant-weiten Optionen heraus. Das
|
||||
* Kontoauszug-Format etwa beschreibt die Bank, nicht die Zusage an die Teilnehmenden -- eingefroren
|
||||
* ließe sich nach einem Bankwechsel für laufende Aktionen nichts mehr importieren. IBAN und
|
||||
* Kontoinhaber frieren dagegen weiterhin pro Aktion ein.
|
||||
*
|
||||
* @param array<string, mixed> $configuration
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function eventConfiguration(string $slug, array $configuration): array
|
||||
{
|
||||
return PaymentMethod::stripTenantScopedOptions(
|
||||
$slug,
|
||||
PaymentMethod::sanitizeConfiguration($slug, $configuration),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Controllers;
|
||||
|
||||
use App\Domains\Event\Actions\BookBankStatementPayments\BookBankStatementPaymentsCommand;
|
||||
use App\Domains\Event\Actions\BookBankStatementPayments\BookBankStatementPaymentsRequest;
|
||||
use App\Scopes\CommonController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Bucht die in der Prüfansicht bestätigten Zahlungseingänge -- ein Aufruf für alle Zeilen.
|
||||
*/
|
||||
class BankStatementBookController extends CommonController
|
||||
{
|
||||
public function __invoke(int $eventId, Request $request): JsonResponse
|
||||
{
|
||||
$event = $this->events->getById($eventId);
|
||||
|
||||
if ($event === null) {
|
||||
return response()->json(['status' => 'error', 'message' => 'Die Veranstaltung wurde nicht gefunden.']);
|
||||
}
|
||||
|
||||
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
|
||||
event: $event,
|
||||
bookings: (array) $request->input('bookings', []),
|
||||
))->execute();
|
||||
|
||||
return response()->json([
|
||||
'status' => $response->success ? 'success' : 'error',
|
||||
'message' => $response->message,
|
||||
'booked' => $response->booked,
|
||||
'skipped' => $response->skipped,
|
||||
'failed' => $response->failed,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Controllers;
|
||||
|
||||
use App\Domains\Event\Actions\ParseBankStatement\ParseBankStatementCommand;
|
||||
use App\Domains\Event\Actions\ParseBankStatement\ParseBankStatementRequest;
|
||||
use App\Models\PaymentMethod;
|
||||
use App\Scopes\CommonController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Nimmt den CSV-Export der Bank entgegen und liefert die Prüfansicht zurück. Gebucht wird hier nichts.
|
||||
*/
|
||||
class BankStatementParseController extends CommonController
|
||||
{
|
||||
public function __invoke(int $eventId, Request $request): JsonResponse
|
||||
{
|
||||
$event = $this->events->getById($eventId);
|
||||
|
||||
if ($event === null) {
|
||||
return response()->json(['status' => 'error', 'message' => 'Die Veranstaltung wurde nicht gefunden.']);
|
||||
}
|
||||
|
||||
// Das Kontoauszug-Format kommt vom Mandanten, nicht aus dem Event-Snapshot: Ein Bankwechsel
|
||||
// muss sofort auch für laufende Aktionen gelten.
|
||||
$configuration = $this->paymentMethods->tenantConfiguration(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION);
|
||||
|
||||
$response = new ParseBankStatementCommand(new ParseBankStatementRequest(
|
||||
event: $event,
|
||||
file: $request->file('statement'),
|
||||
configuration: $configuration,
|
||||
))->execute();
|
||||
|
||||
return response()->json([
|
||||
'status' => $response->success ? 'success' : 'error',
|
||||
'message' => $response->message,
|
||||
'rows' => $response->rows,
|
||||
'participants' => $response->participants,
|
||||
'skipped' => $response->skippedOlderThanWatermark,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Event\Controllers\BankStatementBookController;
|
||||
use App\Domains\Event\Controllers\BankStatementParseController;
|
||||
use App\Domains\Event\Controllers\CreateController;
|
||||
use App\Domains\Event\Controllers\DetailsController;
|
||||
use App\Domains\Event\Controllers\EventArchiveController;
|
||||
@@ -48,6 +50,9 @@ Route::prefix('api/v1')
|
||||
Route::post('/payment-methods', [DetailsController::class, 'updatePaymentMethods']);
|
||||
Route::post('/participation-options', [DetailsController::class, 'updateParticipationOptions']);
|
||||
Route::post('/event-addons', [DetailsController::class, 'updateEventAddons']);
|
||||
|
||||
Route::post('/bank-statement/parse', BankStatementParseController::class);
|
||||
Route::post('/bank-statement/book', BankStatementBookController::class);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
<script setup>
|
||||
import {computed, ref} from 'vue'
|
||||
import {toast} from 'vue3-toastify'
|
||||
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
|
||||
import LoadingModal from "../../../../Views/Components/LoadingModal.vue";
|
||||
import Icon from "../../../../Views/Components/Icon.vue";
|
||||
import {useAjax} from "../../../../../resources/js/components/ajaxHandler.js";
|
||||
|
||||
/**
|
||||
* Zahlungseingänge aus dem CSV-Export der Bank einbuchen.
|
||||
*
|
||||
* Zwei Schritte: Datei einlesen (der Server ordnet zu und schlägt vor), dann die Prüfansicht
|
||||
* bestätigen. Gebucht wird erst im zweiten Schritt und für alle Zeilen in einem Aufruf.
|
||||
*/
|
||||
const props = defineProps({
|
||||
event: {type: Object, required: true},
|
||||
})
|
||||
const emit = defineEmits(['close', 'booked'])
|
||||
|
||||
const {request} = useAjax()
|
||||
|
||||
const fileInput = ref(null)
|
||||
const fileName = ref('')
|
||||
const parsing = ref(false)
|
||||
const booking = ref(false)
|
||||
|
||||
const rows = ref([])
|
||||
const participants = ref([])
|
||||
// rowNumber -> identifier ('' = ignorieren)
|
||||
const assignment = ref({})
|
||||
const parsed = ref(false)
|
||||
const skipped = ref(0)
|
||||
|
||||
const assignedCount = computed(
|
||||
() => rows.value.filter(row => isAssigned(row)).length,
|
||||
)
|
||||
|
||||
const openCount = computed(() => rows.value.length - assignedCount.value)
|
||||
|
||||
const participantsByIdentifier = computed(
|
||||
() => Object.fromEntries(participants.value.map(participant => [participant.identifier, participant])),
|
||||
)
|
||||
|
||||
function isAssigned(row) {
|
||||
return (assignment.value[row.rowNumber] ?? '') !== ''
|
||||
}
|
||||
|
||||
/** Der sichtbare Button klickt das versteckte Datei-Element -- wie beim Beleg-Upload der Abrechnung. */
|
||||
function chooseFile() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
/** Auswählen und Einlesen sind ein Schritt: Ein zweiter Klick brächte nichts zu entscheiden. */
|
||||
async function onFileChosen(event) {
|
||||
const file = event.target.files?.[0] ?? null
|
||||
if (file === null) {
|
||||
return
|
||||
}
|
||||
|
||||
fileName.value = file.name
|
||||
await readStatement(file)
|
||||
}
|
||||
|
||||
async function readStatement(file) {
|
||||
parsing.value = true
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('statement', file)
|
||||
|
||||
const response = await request(
|
||||
'/api/v1/event/details/' + props.event.id + '/bank-statement/parse',
|
||||
{method: 'POST', body: form},
|
||||
)
|
||||
|
||||
if (response?.status !== 'success') {
|
||||
toast.error(response?.message ?? 'Die Datei konnte nicht gelesen werden.')
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
rows.value = response.rows ?? []
|
||||
participants.value = response.participants ?? []
|
||||
skipped.value = response.skipped ?? 0
|
||||
// Vorschläge sind vorbelegt, aber nichts ist entschieden -- gebucht wird nur, was hier
|
||||
// stehen bleibt.
|
||||
assignment.value = Object.fromEntries(
|
||||
rows.value.map(row => [row.rowNumber, row.suggestedIdentifier ?? '']),
|
||||
)
|
||||
parsed.value = true
|
||||
} finally {
|
||||
parsing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function bookPayments() {
|
||||
const bookings = rows.value
|
||||
.filter(row => isAssigned(row))
|
||||
.map(row => ({
|
||||
participantIdentifier: assignment.value[row.rowNumber],
|
||||
rowNumber: row.rowNumber,
|
||||
paymentDate: row.paymentDate,
|
||||
amount: row.amount,
|
||||
payerName: row.payerName,
|
||||
payerIban: row.payerIban,
|
||||
purpose: row.purpose,
|
||||
}))
|
||||
|
||||
if (bookings.length === 0) {
|
||||
toast.error('Es ist keine Zahlung zugeordnet.')
|
||||
return
|
||||
}
|
||||
|
||||
booking.value = true
|
||||
try {
|
||||
const response = await request(
|
||||
'/api/v1/event/details/' + props.event.id + '/bank-statement/book',
|
||||
{method: 'POST', body: {bookings}},
|
||||
)
|
||||
|
||||
if (response?.status !== 'success') {
|
||||
toast.error(response?.message ?? 'Die Zahlungen konnten nicht gebucht werden.')
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(response.message)
|
||||
emit('booked')
|
||||
emit('close')
|
||||
} finally {
|
||||
booking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
rows.value = []
|
||||
participants.value = []
|
||||
assignment.value = {}
|
||||
parsed.value = false
|
||||
skipped.value = 0
|
||||
fileName.value = ''
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
function optionLabel(participant) {
|
||||
const state = participant.isSettled
|
||||
? 'vollständig bezahlt'
|
||||
: participant.amountOpen + ' offen'
|
||||
|
||||
return participant.isSignedOff
|
||||
? participant.name + ' — abgemeldet, ' + state
|
||||
: participant.name + ' — ' + state
|
||||
}
|
||||
|
||||
function assignedParticipant(row) {
|
||||
return participantsByIdentifier.value[assignment.value[row.rowNumber]] ?? null
|
||||
}
|
||||
|
||||
/** Der Zustand einer Zeile: was gebucht wird, was noch offen ist, was geprüft gehört. */
|
||||
function rowState(row) {
|
||||
if (!isAssigned(row)) {
|
||||
return row.suggestedIdentifier === null
|
||||
? {key: 'unmatched', label: 'Keine Zuordnung', icon: 'circle-question'}
|
||||
: {key: 'ignored', label: 'Ignoriert', icon: 'ban'}
|
||||
}
|
||||
|
||||
if (assignment.value[row.rowNumber] !== row.suggestedIdentifier) {
|
||||
return {key: 'manual', label: 'Von Hand', icon: 'user-pen'}
|
||||
}
|
||||
|
||||
return row.confidence === 'unsicher'
|
||||
? {key: 'uncertain', label: 'Bitte prüfen', icon: 'triangle-exclamation'}
|
||||
: {key: 'certain', label: 'Vorschlag', icon: 'check'}
|
||||
}
|
||||
|
||||
function lastPaymentOf(row) {
|
||||
return assignedParticipant(row)?.lastPaymentDate ?? null
|
||||
}
|
||||
|
||||
/** Eine Zahlung auf eine abgemeldete Anmeldung zieht eine Erstattung nach sich. */
|
||||
function signedOffOf(row) {
|
||||
const participant = assignedParticipant(row)
|
||||
|
||||
return participant?.isSignedOff === true ? participant : null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FullScreenModal :show="true" @close="emit('close')">
|
||||
<div class="import">
|
||||
<header class="import-header">
|
||||
<h2>Zahlungseingänge einlesen</h2>
|
||||
<p class="subtitle">{{ event.name }}</p>
|
||||
</header>
|
||||
|
||||
<!-- Schritt 1: Datei wählen. Das Datei-Element bleibt versteckt, geklickt wird der Button. -->
|
||||
<div v-if="!parsed" class="dropzone">
|
||||
<Icon name="file-csv" class="dropzone-icon"/>
|
||||
<p class="dropzone-title">Kontoauszug als CSV hochladen</p>
|
||||
<p class="dropzone-note">
|
||||
Der Export wird nur gelesen und nicht gespeichert. Gebucht wird anschließend
|
||||
ausschließlich das, was ihr in der Prüfansicht bestätigt.
|
||||
</p>
|
||||
<input type="button" value="Kontoauszug auswählen" @click="chooseFile"/>
|
||||
</div>
|
||||
|
||||
<input ref="fileInput" type="file" accept=".csv,text/csv,text/plain"
|
||||
style="display: none" @change="onFileChosen"/>
|
||||
|
||||
<template v-if="parsed">
|
||||
<div class="filebar">
|
||||
<span class="filename"><Icon name="file-csv"/> {{ fileName }}</span>
|
||||
<label class="link" @click="reset">Andere Datei wählen</label>
|
||||
</div>
|
||||
|
||||
<div v-if="rows.length > 0" class="stats">
|
||||
<div class="stat stat-assigned">
|
||||
<span class="stat-value">{{ assignedCount }}</span>
|
||||
<span class="stat-label">wird gebucht</span>
|
||||
</div>
|
||||
<div class="stat" :class="{'stat-open': openCount > 0}">
|
||||
<span class="stat-value">{{ openCount }}</span>
|
||||
<span class="stat-label">nicht zugeordnet</span>
|
||||
</div>
|
||||
<div v-if="skipped > 0" class="stat">
|
||||
<span class="stat-value">{{ skipped }}</span>
|
||||
<span class="stat-label">bereits erfasst</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="rows.length === 0" class="empty">
|
||||
<Icon name="circle-check" class="empty-icon"/>
|
||||
<p>
|
||||
In dieser Datei sind keine offenen Zahlungseingänge zu dieser Aktion enthalten.
|
||||
<template v-if="skipped > 0">
|
||||
{{ skipped }} Zahlungen wurden bereits früher eingebucht.
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<table v-else class="statement-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Buchung</th>
|
||||
<th class="right">Betrag</th>
|
||||
<th>Zahler*in</th>
|
||||
<th>Verwendungszweck</th>
|
||||
<th class="assignment-column">Zuordnung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.rowNumber" :class="'state-' + rowState(row).key">
|
||||
<td class="nowrap">{{ row.paymentDateFormatted }}</td>
|
||||
<td class="right amount">{{ row.amountFormatted }}</td>
|
||||
<td>
|
||||
<span class="payer">{{ row.payerName || '—' }}</span>
|
||||
<span v-if="row.payerIban" class="iban">{{ row.payerIban }}</span>
|
||||
</td>
|
||||
<td class="purpose">{{ row.purpose }}</td>
|
||||
<td>
|
||||
<div class="assignment">
|
||||
<select v-model="assignment[row.rowNumber]" class="assignment-select">
|
||||
<option value="">— ignorieren —</option>
|
||||
<option v-for="participant in participants" :key="participant.identifier"
|
||||
:value="participant.identifier">
|
||||
{{ optionLabel(participant) }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="pill" :class="'pill-' + rowState(row).key">
|
||||
<Icon :key="rowState(row).icon" :name="rowState(row).icon"/> {{ rowState(row).label }}
|
||||
</span>
|
||||
<span v-if="signedOffOf(row)" class="pill pill-signedoff">
|
||||
<Icon name="user-slash"/> Abgemeldet
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="signedOffOf(row)" class="hint hint-warning">
|
||||
Am {{ signedOffOf(row).signedOffAt }} abgemeldet — die Zahlung wird
|
||||
erfasst, danach steht eine Erstattung an.
|
||||
</span>
|
||||
<span v-if="lastPaymentOf(row)" class="hint">
|
||||
Zuletzt erfasst: {{ lastPaymentOf(row) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<footer v-if="rows.length > 0" class="actions">
|
||||
<span class="actions-note">
|
||||
Die Teilnehmenden erhalten je Buchung die gewohnte Zahlungsmail.
|
||||
</span>
|
||||
<input type="button" class="accept-button"
|
||||
:value="assignedCount === 1 ? '1 Zahlung einbuchen' : assignedCount + ' Zahlungen einbuchen'"
|
||||
:disabled="assignedCount === 0 || booking"
|
||||
@click="bookPayments"/>
|
||||
</footer>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Beim Buchen geht je Zahlung eine Mail an die Teilnehmenden raus, und zwar synchron --
|
||||
bei achtzig Buchungen dauert der Aufruf entsprechend. -->
|
||||
<LoadingModal v-if="parsing || booking" :show="true"
|
||||
:message="booking
|
||||
? 'Die Zahlungen werden gebucht und die Teilnehmenden benachrichtigt. Das kann einen Moment dauern …'
|
||||
: 'Der Kontoauszug wird gelesen …'"/>
|
||||
</FullScreenModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.import {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.import-header {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.import-header h2 {
|
||||
margin: 0;
|
||||
color: #1d4899;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 2px 0 0 0;
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ── Schritt 1: Datei wählen ───────────────────────────────────────────── */
|
||||
|
||||
.dropzone {
|
||||
border: 2px dashed #c7d2e8;
|
||||
border-radius: 10px;
|
||||
background-color: #fafbfe;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
max-width: 620px;
|
||||
margin: 40px auto;
|
||||
}
|
||||
|
||||
.dropzone-icon {
|
||||
font-size: 2.6rem;
|
||||
color: #809dd5;
|
||||
}
|
||||
|
||||
.dropzone-title {
|
||||
margin: 14px 0 6px 0;
|
||||
font-weight: bold;
|
||||
font-size: 1.05rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dropzone-note {
|
||||
margin: 0 auto 22px auto;
|
||||
max-width: 440px;
|
||||
color: #6b7280;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Schritt 2: Prüfansicht ────────────────────────────────────────────── */
|
||||
|
||||
.filebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 12px;
|
||||
background-color: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filename {
|
||||
color: #374151;
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.stat {
|
||||
flex: 1 1 130px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #374151;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.stat-assigned {
|
||||
border-color: #508c4c;
|
||||
background-color: #f3faf3;
|
||||
}
|
||||
|
||||
.stat-assigned .stat-value {
|
||||
color: #2f6b2c;
|
||||
}
|
||||
|
||||
.stat-open .stat-value {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 2rem;
|
||||
color: #508c4c;
|
||||
}
|
||||
|
||||
.statement-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.statement-table th {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
background-color: #f9fafb;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #d1d5db;
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.statement-table td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
vertical-align: top;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.statement-table .right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.amount {
|
||||
white-space: nowrap;
|
||||
font-weight: bold;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.assignment-column {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
/* Zeilenfarbe sagt auf einen Blick, was passiert: grün wird gebucht, gelb will geprüft werden,
|
||||
grau bleibt liegen. */
|
||||
.state-certain td,
|
||||
.state-manual td {
|
||||
background-color: #f6fdf7;
|
||||
}
|
||||
|
||||
.state-uncertain td {
|
||||
background-color: #fffbeb;
|
||||
}
|
||||
|
||||
.state-ignored td,
|
||||
.state-unmatched td {
|
||||
background-color: #fbfbfb;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.payer {
|
||||
display: block;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.iban {
|
||||
display: block;
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.purpose {
|
||||
max-width: 320px;
|
||||
word-break: break-word;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.assignment {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.assignment-select {
|
||||
width: 100%;
|
||||
padding: 5px 6px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background-color: #ffffff;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 11px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: bold;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.pill-certain {
|
||||
color: #2f6b2c;
|
||||
background-color: #dcfce7;
|
||||
border-color: #86c884;
|
||||
}
|
||||
|
||||
.pill-manual {
|
||||
color: #1d4899;
|
||||
background-color: #e4ecfb;
|
||||
border-color: #809dd5;
|
||||
}
|
||||
|
||||
.pill-uncertain {
|
||||
color: #92400e;
|
||||
background-color: #fef3c7;
|
||||
border-color: #d9b45c;
|
||||
}
|
||||
|
||||
.pill-ignored,
|
||||
.pill-unmatched {
|
||||
color: #6b7280;
|
||||
background-color: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.pill-signedoff {
|
||||
color: #9a3412;
|
||||
background-color: #ffedd5;
|
||||
border-color: #e0a06a;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
color: #9ca3af;
|
||||
font-size: 0.72rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.hint-warning {
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.actions-note {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.actions input[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Schmale Bildschirme ───────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.assignment-column {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.statement-table th {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.purpose {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,7 @@ import EventAddons from "./EventAddons.vue";
|
||||
import ParticipationSummary from "./ParticipationSummary.vue";
|
||||
import CommonSettings from "./CommonSettings.vue";
|
||||
import EventManagement from "./EventManagement.vue";
|
||||
import BankStatementImport from "./BankStatementImport.vue";
|
||||
import Modal from "../../../../Views/Components/Modal.vue";
|
||||
import MailCompose from "./MailCompose.vue";
|
||||
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
|
||||
@@ -21,6 +22,12 @@ const props = defineProps({
|
||||
|
||||
const displayData = ref('main');
|
||||
const showEventData = ref(false);
|
||||
const showBankStatement = ref(false);
|
||||
|
||||
// Nach dem Buchen stimmen die Beitragsstände der Übersicht nicht mehr -- neu laden.
|
||||
async function bankStatementBooked() {
|
||||
await showMain();
|
||||
}
|
||||
|
||||
|
||||
async function showMain() {
|
||||
@@ -117,9 +124,18 @@ async function showEventAddons() {
|
||||
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/photo-permission-list'">
|
||||
<input type="button" value="Foto-Erlaubnis (PDF)" />
|
||||
</a><br/>
|
||||
|
||||
<input type="button" value="Zahlungseingänge einlesen" @click="showBankStatement = true" /><br/>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Liegt außerhalb der displayData-Umschaltung: Der Import ist ein Vollbild-Dialog über der
|
||||
Übersicht, keine Unter-Ansicht, und soll auch aus einer Unter-Ansicht heraus erreichbar sein. -->
|
||||
<BankStatementImport v-if="showBankStatement && dynamicProps.event"
|
||||
:event="dynamicProps.event"
|
||||
@close="showBankStatement = false"
|
||||
@booked="bankStatementBooked" />
|
||||
|
||||
<ParticipationFees v-if="displayData === 'participationFees'" :event="dynamicProps.event" @close="showMain" />
|
||||
<ParticipationOptions v-else-if="displayData === 'participationOptions'" :event="dynamicProps.event"
|
||||
@close="showMain"/>
|
||||
@@ -158,6 +174,7 @@ async function showEventAddons() {
|
||||
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/photo-permission-list'">
|
||||
<input type="button" value="Foto-Erlaubnis (PDF)" />
|
||||
</a><br/>
|
||||
<input type="button" value="Zahlungseingänge einlesen" @click="showBankStatement = true" /><br/>
|
||||
<input type="button" @click="sendPaymentReminder" class="fix-button" value="Zahlungserinnerung senden" /><br/>
|
||||
<input type="button" class="deny-button" value="Letzte Mahnung senden" style="display: none" /><br/>
|
||||
<input type="button" value="Rundmail senden" @click="mailToGroup" /><br/>
|
||||
|
||||
@@ -26,6 +26,18 @@ const paymentMethodConfigurations = reactive({})
|
||||
const showOptionsModal = ref(false)
|
||||
const optionsMethod = ref(null)
|
||||
|
||||
/**
|
||||
* Die Optionen, die pro Aktion eingefroren werden.
|
||||
*
|
||||
* Optionen mit `scope: 'tenant'` bleiben draußen: Sie gelten für den ganzen Mandanten. Das
|
||||
* Kontoauszug-Format etwa beschreibt die Bank, nicht die Zusage an die Teilnehmenden — hier
|
||||
* eingefroren ließe sich nach einem Bankwechsel für laufende Aktionen nichts mehr importieren.
|
||||
* Der Server hält sich beim Speichern an dieselbe Regel.
|
||||
*/
|
||||
function eventOptions(method) {
|
||||
return (method?.optionsSchema ?? []).filter(option => option.scope !== 'tenant')
|
||||
}
|
||||
|
||||
function openOptions(method) {
|
||||
optionsMethod.value = method
|
||||
showOptionsModal.value = true
|
||||
@@ -47,7 +59,7 @@ onMounted(async () => {
|
||||
for (const method of availablePaymentMethods.value) {
|
||||
const source = eventConfigBySlug[method.slug] ?? method.configuration ?? {}
|
||||
const config = {}
|
||||
for (const option of method.optionsSchema ?? []) {
|
||||
for (const option of eventOptions(method)) {
|
||||
config[option.name] = source[option.name] ?? ''
|
||||
}
|
||||
paymentMethodConfigurations[method.slug] = config
|
||||
@@ -358,7 +370,7 @@ onMounted(async () => {
|
||||
paymentMethod.name
|
||||
}}</label>
|
||||
<label
|
||||
v-if="paymentMethods.includes(paymentMethod.slug) && (paymentMethod.optionsSchema?.length ?? 0) > 0"
|
||||
v-if="paymentMethods.includes(paymentMethod.slug) && eventOptions(paymentMethod).length > 0"
|
||||
class="link"
|
||||
style="padding-left: 15px; font-size: 10pt;"
|
||||
@click="openOptions(paymentMethod)"
|
||||
@@ -381,7 +393,7 @@ onMounted(async () => {
|
||||
width="700px"
|
||||
@close="showOptionsModal = false"
|
||||
>
|
||||
<div v-for="option in optionsMethod?.optionsSchema ?? []" :key="option.name" class="payment-method-config-row">
|
||||
<div v-for="option in eventOptions(optionsMethod)" :key="option.name" class="payment-method-config-row">
|
||||
<label>{{ option.label }}<span v-if="option.required" class="required-marker">*</span></label>
|
||||
<RichSelectBox v-if="option.type === 'icon'"
|
||||
v-model="paymentMethodConfigurations[optionsMethod.slug][option.name]"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -74,6 +74,8 @@ class EventParticipant extends InstancedModel
|
||||
'payment_purpose',
|
||||
'payment_method',
|
||||
'payment_options',
|
||||
'refund_data',
|
||||
'last_payment_date',
|
||||
'efz_status',
|
||||
'unregistered_at',
|
||||
];
|
||||
@@ -94,6 +96,8 @@ class EventParticipant extends InstancedModel
|
||||
'amount' => AmountCast::class,
|
||||
'amount_paid' => AmountCast::class,
|
||||
'payment_options' => 'array',
|
||||
'refund_data' => 'boolean',
|
||||
'last_payment_date' => 'date',
|
||||
|
||||
'invoice_sequence' => 'integer',
|
||||
'sibling_reduction' => 'boolean',
|
||||
|
||||
@@ -58,13 +58,15 @@ class PaymentMethod extends CommonModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Teilnehmer-Eingabe-Schema für einen Slug (payer-seitig).
|
||||
* Teilnehmer-Eingabe-Schema für einen Slug (payer-seitig) -- das, was das Anmeldeformular
|
||||
* abfragt. Programmatisch befüllte Felder (`system => true`, z.B. das vom Zahlungsimport
|
||||
* nachgetragene Zahler-Konto) sind hier bewusst nicht dabei.
|
||||
*
|
||||
* @return array<int, array{name: string, label: string, type: string, required: bool}>
|
||||
*/
|
||||
public static function participantOptionsFor(string $slug): array
|
||||
{
|
||||
return EventPaymentModuleRegistry::forSlug($slug)?->getParticipantOptions() ?? [];
|
||||
return EventPaymentModuleRegistry::forSlug($slug)?->participantInputOptions() ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +90,20 @@ class PaymentMethod extends CommonModel
|
||||
return EventPaymentModuleRegistry::forSlug($slug)?->sanitizeConfiguration($config) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt die tenant-weiten Optionen eines Slugs aus einer Konfiguration.
|
||||
*
|
||||
* Aufgerufen beim Copy-on-Assign an eine Veranstaltung: Was den Mandanten als Ganzes betrifft
|
||||
* (z.B. das Kontoauszug-Format), darf nicht pro Aktion einfrieren.
|
||||
*
|
||||
* @param array<string, mixed> $config
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function stripTenantScopedOptions(string $slug, array $config): array
|
||||
{
|
||||
return EventPaymentModuleRegistry::forSlug($slug)?->stripTenantScopedOptions($config) ?? $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob alle Pflicht-Optionen eines Slugs in der Konfiguration befüllt (non-empty) sind.
|
||||
* Unbekannte Slugs (kein Modul) gelten als vollständig -- kein Modul, keine Pflichtfelder.
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Support\BankStatementParseException;
|
||||
use App\ValueObjects\Amount;
|
||||
use App\ValueObjects\BankStatementRuleset;
|
||||
use App\ValueObjects\BankTransaction;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/**
|
||||
* Liest den CSV-Export der Bank in {@see BankTransaction}-Objekte.
|
||||
*
|
||||
* Zustandslos und zahlartneutral: hier wird nur gelesen, nicht bewertet. Ob ein Umsatz überhaupt
|
||||
* interessiert, entscheidet das Zahlungsmodul über `isRelevantTransaction()` -- die Überweisung will
|
||||
* Gutschriften, die SEPA-Lastschrift später genau andersherum Belastungen und Rücklastschriften.
|
||||
*/
|
||||
class BankStatementParseProvider
|
||||
{
|
||||
/**
|
||||
* @return array<int, BankTransaction>
|
||||
*
|
||||
* @throws BankStatementParseException wenn eine Pflichtspalte im Export fehlt
|
||||
*/
|
||||
public function parse(string $contents, BankStatementRuleset $ruleset): array
|
||||
{
|
||||
$missing = $ruleset->missingRequiredColumns();
|
||||
if ($missing !== []) {
|
||||
throw new BankStatementParseException(
|
||||
'Im Kontoauszug-Format fehlt die Zuordnung für: ' . implode(', ', $missing)
|
||||
. '. Bitte die Spaltenzuordnung in den Zahlungsmethoden prüfen.'
|
||||
);
|
||||
}
|
||||
|
||||
$lines = $this->lines($this->toUtf8($contents, $ruleset->charset));
|
||||
if ($lines === []) {
|
||||
throw new BankStatementParseException('Die Datei enthält keine Daten.');
|
||||
}
|
||||
|
||||
// Ohne Kopfzeile ließen sich die Spalten nur über ihre Position ansprechen -- dann bräche der
|
||||
// Import stillschweigend, sobald die Bank eine Spalte einfügt. Lieber hier abbrechen.
|
||||
if (!$ruleset->hasHeader) {
|
||||
throw new BankStatementParseException('Der Export muss eine Kopfzeile mit den Spaltennamen enthalten.');
|
||||
}
|
||||
|
||||
$indexes = $this->resolveColumnIndexes(array_shift($lines), $ruleset);
|
||||
|
||||
$transactions = [];
|
||||
|
||||
foreach ($lines as $offset => $line) {
|
||||
$fields = $this->splitLine($line, $ruleset);
|
||||
|
||||
$date = $this->parseDate($this->field($fields, $indexes, 'payment_date'), $ruleset);
|
||||
$amount = $this->parseAmount($this->field($fields, $indexes, 'amount'), $ruleset);
|
||||
|
||||
// Zeilen ohne lesbares Datum oder ohne Betrag sind keine Umsätze (Summenzeilen, Fußnoten,
|
||||
// Leerzeilen mit Trennzeichen) -- die werden still übergangen statt den Import abzubrechen.
|
||||
if ($date === null || $amount === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$transactions[] = new BankTransaction(
|
||||
paymentDate: $date,
|
||||
amount: $amount,
|
||||
purpose: $this->field($fields, $indexes, 'purpose'),
|
||||
payerName: $this->field($fields, $indexes, 'payer_name'),
|
||||
payerIban: $this->field($fields, $indexes, 'payer_iban'),
|
||||
// +2: die Kopfzeile ist Zeile 1, $offset zählt ab 0 in der Restliste.
|
||||
rowNumber: $offset + 2,
|
||||
);
|
||||
}
|
||||
|
||||
return $transactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bringt den Inhalt nach UTF-8.
|
||||
*
|
||||
* Der Schutz gegen Doppel-Kodierung ist der wichtige Teil: liegt bereits gültiges UTF-8 mit
|
||||
* Mehrbyte-Zeichen vor, wird nicht noch einmal konvertiert. Sonst würde aus „Müller" ein
|
||||
* „Müller" -- und zwar unbemerkt, weil die Konvertierung technisch gelingt.
|
||||
*/
|
||||
private function toUtf8(string $contents, string $charset): string
|
||||
{
|
||||
$contents = preg_replace('/^\xEF\xBB\xBF/', '', $contents) ?? $contents;
|
||||
|
||||
$isUtf8 = mb_check_encoding($contents, 'UTF-8');
|
||||
$hasMultiByte = strlen($contents) !== mb_strlen($contents, 'UTF-8');
|
||||
|
||||
if ($isUtf8 && $hasMultiByte) {
|
||||
return $contents;
|
||||
}
|
||||
|
||||
if ($charset === 'auto') {
|
||||
return $isUtf8 ? $contents : (string) mb_convert_encoding($contents, 'UTF-8', 'Windows-1252');
|
||||
}
|
||||
|
||||
if ($charset === 'UTF-8') {
|
||||
return $contents;
|
||||
}
|
||||
|
||||
return (string) mb_convert_encoding($contents, 'UTF-8', $charset);
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
private function lines(string $contents): array
|
||||
{
|
||||
$lines = preg_split('/\r\n|\r|\n/', $contents) ?: [];
|
||||
|
||||
return array_values(array_filter($lines, static fn (string $line): bool => trim($line) !== ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fachliches Feld => Spaltenindex, aufgelöst über die Kopfzeile.
|
||||
*
|
||||
* @return array<string, int>
|
||||
*
|
||||
* @throws BankStatementParseException
|
||||
*/
|
||||
private function resolveColumnIndexes(string $headerLine, BankStatementRuleset $ruleset): array
|
||||
{
|
||||
$headers = array_map(
|
||||
static fn (string $header): string => mb_strtolower(trim($header)),
|
||||
$this->splitLine($headerLine, $ruleset),
|
||||
);
|
||||
|
||||
$indexes = [];
|
||||
$notFound = [];
|
||||
|
||||
foreach ($ruleset->columns as $field => $column) {
|
||||
$index = array_search(mb_strtolower(trim($column)), $headers, true);
|
||||
|
||||
if ($index === false) {
|
||||
if (in_array($field, BankStatementRuleset::REQUIRED_COLUMNS, true)) {
|
||||
$notFound[] = $column;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$indexes[$field] = (int) $index;
|
||||
}
|
||||
|
||||
if ($notFound !== []) {
|
||||
throw new BankStatementParseException(
|
||||
'Die Datei enthält keine Spalte "' . implode('", "', $notFound) . '". '
|
||||
. 'Entweder wurde der falsche Export hochgeladen, oder das eingestellte '
|
||||
. 'Kontoauszug-Format passt nicht zu dieser Bank.'
|
||||
);
|
||||
}
|
||||
|
||||
return $indexes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function splitLine(string $line, BankStatementRuleset $ruleset): array
|
||||
{
|
||||
// str_getcsv() wirft bei leerem Enclosure einen ValueError. Ohne Anführungszeichen -- so
|
||||
// liefert es die GLS -- ist ein schlichtes explode() ohnehin das Richtige.
|
||||
$fields = $ruleset->enclosure === ''
|
||||
? explode($ruleset->delimiter, $line)
|
||||
: str_getcsv($line, $ruleset->delimiter, $ruleset->enclosure, '\\');
|
||||
|
||||
return array_map(static fn ($field): string => trim((string) $field), $fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $fields
|
||||
* @param array<string, int> $indexes
|
||||
*/
|
||||
private function field(array $fields, array $indexes, string $name): string
|
||||
{
|
||||
$index = $indexes[$name] ?? null;
|
||||
|
||||
return $index === null ? '' : ($fields[$index] ?? '');
|
||||
}
|
||||
|
||||
private function parseDate(string $value, BankStatementRuleset $ruleset): ?CarbonImmutable
|
||||
{
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$date = CarbonImmutable::createFromFormat('!' . $ruleset->dateFormat, $value);
|
||||
|
||||
return $date === false ? null : $date;
|
||||
}
|
||||
|
||||
/** „1.234,56" bzw. „-56,00" -> Amount. */
|
||||
private function parseAmount(string $value, BankStatementRuleset $ruleset): ?Amount
|
||||
{
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = $value;
|
||||
if ($ruleset->thousandsSeparator !== '') {
|
||||
$normalized = str_replace($ruleset->thousandsSeparator, '', $normalized);
|
||||
}
|
||||
$normalized = str_replace($ruleset->decimalSeparator, '.', $normalized);
|
||||
$normalized = str_replace(' ', '', $normalized);
|
||||
|
||||
if (!is_numeric($normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Amount((float) $normalized, 'Euro');
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Enumerations\EatingHabit;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventParticipant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EventParticipantRepository {
|
||||
public function getForList(Event $event, Request $request, bool $signedOffParticipants = false) : array {
|
||||
@@ -39,6 +40,39 @@ class EventParticipantRepository {
|
||||
return $participant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anmeldungen, gegen die ein Zahlungseingang zugeordnet werden kann.
|
||||
*
|
||||
* Abgemeldete sind ausdrücklich dabei: Wer den Beitrag überwiesen und sich danach abgemeldet hat,
|
||||
* steht trotzdem im Kontoauszug. Die Zahlung muss erfasst werden -- erst dann gibt es überhaupt
|
||||
* etwas zu erstatten. Ließe man sie hier weg, bliebe der Eingang unzuordenbar und das Geld läge
|
||||
* unbemerkt auf dem Konto.
|
||||
*
|
||||
* `event.paymentMethods` wird mitgeladen, weil die Zuordnung je Anmeldung das Zahlungsmodul
|
||||
* auflöst und das sonst pro Zeile eine Abfrage wäre.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection<int, EventParticipant>
|
||||
*/
|
||||
public function getForPaymentMatching(Event $event) : Collection {
|
||||
return $event->participants()
|
||||
->with('event.paymentMethods')
|
||||
->orderBy('lastname')
|
||||
->orderBy('firstname')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Anmeldung dieser Veranstaltung -- der Identifier kommt beim Zahlungsimport aus dem Browser
|
||||
* und wird deshalb hier gegen die Aktion geprüft. Der Tenant-Filter kommt vom SiteScope.
|
||||
*
|
||||
* Abgemeldete eingeschlossen, aus demselben Grund wie in {@see getForPaymentMatching()}.
|
||||
*/
|
||||
public function findInEventByIdentifier(Event $event, string $identifier) : ?EventParticipant {
|
||||
return EventParticipant::where('identifier', $identifier)
|
||||
->where('event_id', $event->id)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function groupByLocalGroup(Event $event, Request $request, ?string $filter = null) : array {
|
||||
$allParticipants = $this->getForList($event, $request);
|
||||
$participants = [];
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\AvailablePaymentMethod;
|
||||
|
||||
/**
|
||||
* Zugriff auf die Zahlungsmethoden-Instanzen des Mandanten.
|
||||
*
|
||||
* Existiert, damit die Zahlungsmodule DB-frei bleiben können: Sie bekommen ihre Konfiguration als
|
||||
* Array hereingereicht ({@see \App\EventPaymentModules\ProvidesStatementRuleset}) und holen sie sich
|
||||
* nicht selbst -- sonst bräuchte jeder Modul-Unit-Test eine Datenbank.
|
||||
*/
|
||||
class PaymentMethodRepository
|
||||
{
|
||||
/**
|
||||
* Konfiguration einer Zahlungsart beim aktuellen Mandanten. Der Tenant-Filter kommt vom SiteScope.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function tenantConfiguration(string $slug): array
|
||||
{
|
||||
return (array) (AvailablePaymentMethod::where('slug', $slug)->first()?->configuration ?? []);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use App\Repositories\EventRepository;
|
||||
use App\Repositories\InvoiceRepository;
|
||||
use App\Repositories\PageTextRepository;
|
||||
use App\Repositories\ParticipantRefundRepository;
|
||||
use App\Repositories\PaymentMethodRepository;
|
||||
use App\Repositories\UserRepository;
|
||||
|
||||
abstract class CommonController {
|
||||
@@ -27,6 +28,7 @@ abstract class CommonController {
|
||||
protected EventParticipantRepository $eventParticipants;
|
||||
protected ParticipantRefundRepository $participantRefunds;
|
||||
protected EstimatesRepository $estimates;
|
||||
protected PaymentMethodRepository $paymentMethods;
|
||||
protected AdminUserRepository $adminUsers;
|
||||
protected AdminTenantRepository $adminTenants;
|
||||
|
||||
@@ -40,6 +42,7 @@ abstract class CommonController {
|
||||
$this->eventParticipants = new EventParticipantRepository();
|
||||
$this->participantRefunds = new ParticipantRefundRepository();
|
||||
$this->estimates = new EstimatesRepository();
|
||||
$this->paymentMethods = new PaymentMethodRepository();
|
||||
$this->adminUsers = new AdminUserRepository();
|
||||
$this->adminTenants = new AdminTenantRepository();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Die hochgeladene Datei passt nicht zum eingestellten Ruleset.
|
||||
*
|
||||
* Die Meldung ist für die Aktionsleitung gedacht und wird unverändert angezeigt -- sie muss also
|
||||
* sagen, welche Spalte fehlt oder was sonst nicht stimmt, nicht bloß „Fehler beim Einlesen".
|
||||
*/
|
||||
class BankStatementParseException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ValueObjects;
|
||||
|
||||
/**
|
||||
* Wie ist der CSV-Export der Bank aufgebaut? Trennzeichen, Anführungszeichen, Zeichensatz, Zahl- und
|
||||
* Datumsformat sowie die Zuordnung fachliches Feld -> Spaltenüberschrift.
|
||||
*
|
||||
* Zahlartneutral und damit bewusst außerhalb von {@see \App\EventPaymentModules}: Überweisung und
|
||||
* (künftig) SEPA-Lastschrift lesen denselben Kontoauszug, nur werten sie ihn verschieden aus.
|
||||
*
|
||||
* Ein Tenant überschreibt das Ruleset **ganz oder gar nicht** ({@see fromConfiguration()}). Ein
|
||||
* feldweiser Merge wäre die schlechtere Zusage: wer das Trennzeichen umstellt, weil seine Bank ein
|
||||
* anderes Format liefert, bekäme sonst weiterhin die Spaltennamen der GLS untergeschoben und suchte
|
||||
* den Fehler an der falschen Stelle.
|
||||
*/
|
||||
final readonly class BankStatementRuleset
|
||||
{
|
||||
/** Fachliche Felder, ohne die sich eine Zahlung nicht verarbeiten lässt. */
|
||||
public const array REQUIRED_COLUMNS = ['payment_date', 'purpose', 'amount'];
|
||||
|
||||
/** Auswählbare Zeichensätze; 'auto' probiert UTF-8 und fällt sonst auf Windows-1252 zurück. */
|
||||
public const array CHARSETS = ['auto', 'UTF-8', 'Windows-1252', 'ISO-8859-15'];
|
||||
|
||||
/**
|
||||
* @param array<string, string> $columns fachliches Feld => Spaltenüberschrift
|
||||
*/
|
||||
public function __construct(
|
||||
public string $delimiter,
|
||||
public string $enclosure,
|
||||
public string $charset,
|
||||
public bool $hasHeader,
|
||||
public string $dateFormat,
|
||||
public string $decimalSeparator,
|
||||
public string $thousandsSeparator,
|
||||
public array $columns,
|
||||
) {
|
||||
}
|
||||
|
||||
/** Der app-weite Standard aus config/bankStatement.php. */
|
||||
public static function default(): self
|
||||
{
|
||||
return self::fromArray((array) config('bankStatement.default_ruleset', []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruleset aus einer Modul-Konfiguration. Leerer/fehlender Override -> App-Standard.
|
||||
*
|
||||
* @param array<string, mixed>|null $override
|
||||
*/
|
||||
public static function fromConfiguration(?array $override): self
|
||||
{
|
||||
if ($override === null || $override === []) {
|
||||
return self::default();
|
||||
}
|
||||
|
||||
return self::fromArray($override);
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelne fehlende Schlüssel werden aus dem Config-Standard ergänzt -- das ist kein Widerspruch
|
||||
* zum „ganz oder gar nicht": greift nur, wenn überhaupt ein Override existiert, und verhindert,
|
||||
* dass ein unvollständig gespeichertes Formular einen leeren Delimiter erzeugt.
|
||||
*
|
||||
* @param array<string, mixed> $values
|
||||
*/
|
||||
private static function fromArray(array $values): self
|
||||
{
|
||||
$fallback = (array) config('bankStatement.default_ruleset', []);
|
||||
|
||||
$columns = (array) ($values['columns'] ?? $fallback['columns'] ?? []);
|
||||
$columns = array_filter(
|
||||
array_map(static fn ($column): string => trim((string) $column), $columns),
|
||||
static fn (string $column): bool => $column !== '',
|
||||
);
|
||||
|
||||
$charset = (string) ($values['charset'] ?? $fallback['charset'] ?? 'auto');
|
||||
|
||||
return new self(
|
||||
delimiter: self::firstNonEmpty($values['delimiter'] ?? null, $fallback['delimiter'] ?? null, ';'),
|
||||
// Der Leerstring ist hier ein gültiger Wert (keine Anführungszeichen) und darf nicht
|
||||
// durch einen Fallback ersetzt werden.
|
||||
enclosure: (string) ($values['enclosure'] ?? $fallback['enclosure'] ?? ''),
|
||||
charset: in_array($charset, self::CHARSETS, true) ? $charset : 'auto',
|
||||
hasHeader: (bool) ($values['has_header'] ?? $fallback['has_header'] ?? true),
|
||||
dateFormat: self::firstNonEmpty($values['date_format'] ?? null, $fallback['date_format'] ?? null, 'd.m.Y'),
|
||||
decimalSeparator: self::firstNonEmpty($values['decimal_separator'] ?? null, $fallback['decimal_separator'] ?? null, ','),
|
||||
thousandsSeparator: (string) ($values['thousands_separator'] ?? $fallback['thousands_separator'] ?? '.'),
|
||||
columns: $columns,
|
||||
);
|
||||
}
|
||||
|
||||
private static function firstNonEmpty(mixed ...$candidates): string
|
||||
{
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($candidate !== null && (string) $candidate !== '') {
|
||||
return (string) $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Spaltenüberschrift für ein fachliches Feld, oder null wenn nicht gemappt. */
|
||||
public function column(string $field): ?string
|
||||
{
|
||||
return $this->columns[$field] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pflichtfelder, für die keine Spalte gemappt ist.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function missingRequiredColumns(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
self::REQUIRED_COLUMNS,
|
||||
fn (string $field): bool => $this->column($field) === null,
|
||||
));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'delimiter' => $this->delimiter,
|
||||
'enclosure' => $this->enclosure,
|
||||
'charset' => $this->charset,
|
||||
'has_header' => $this->hasHeader,
|
||||
'date_format' => $this->dateFormat,
|
||||
'decimal_separator' => $this->decimalSeparator,
|
||||
'thousands_separator' => $this->thousandsSeparator,
|
||||
'columns' => $this->columns,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ValueObjects;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/**
|
||||
* Ein Umsatz aus dem Kontoauszug -- das Ergebnis einer geparsten CSV-Zeile, noch ohne jede Zuordnung.
|
||||
*
|
||||
* Zahlartneutral: ob der Umsatz überhaupt interessiert (Gutschrift bei der Überweisung, Belastung und
|
||||
* Rücklastschrift bei der SEPA-Lastschrift) entscheidet das jeweilige Zahlungsmodul, nicht der Parser.
|
||||
*
|
||||
* `amount` trägt das Vorzeichen der Bank: Gutschriften positiv, Belastungen negativ.
|
||||
*/
|
||||
final readonly class BankTransaction
|
||||
{
|
||||
public function __construct(
|
||||
public CarbonImmutable $paymentDate,
|
||||
public Amount $amount,
|
||||
public string $purpose,
|
||||
public string $payerName,
|
||||
public string $payerIban,
|
||||
/** Zeilennummer in der hochgeladenen Datei -- Schlüssel der Zeile in der Prüfansicht. */
|
||||
public int $rowNumber,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isCredit(): bool
|
||||
{
|
||||
return $this->amount->getAmount() > 0;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'rowNumber' => $this->rowNumber,
|
||||
'paymentDate' => $this->paymentDate->format('Y-m-d'),
|
||||
'paymentDateFormatted' => $this->paymentDate->format('d.m.Y'),
|
||||
'amount' => $this->amount->getAmount(),
|
||||
'amountFormatted' => $this->amount->toString(),
|
||||
'purpose' => $this->purpose,
|
||||
'payerName' => $this->payerName,
|
||||
'payerIban' => $this->payerIban,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
<script setup>
|
||||
import {computed, ref, watch} from 'vue'
|
||||
|
||||
/**
|
||||
* Formular für das CSV-Format eines Kontoauszugs (Options-Typ `bank-ruleset`).
|
||||
*
|
||||
* Zwei Zustände: „App-Standard" (v-model leer) und „eigenes Format" (v-model trägt ein JSON-Objekt).
|
||||
* Das ist die Zusage aus dem Backend -- ein Override gilt ganz oder gar nicht, es wird nicht feldweise
|
||||
* mit dem Standard gemischt. Deshalb schaltet hier ein Schalter um und nicht jedes Feld für sich.
|
||||
*
|
||||
* Der Wert geht als JSON-String durch das generische Options-Formular; das Zahlungsmodul dekodiert ihn.
|
||||
*/
|
||||
const props = defineProps({
|
||||
// JSON-String oder Objekt; leer = App-Standard.
|
||||
modelValue: {type: [String, Object], default: ''},
|
||||
// Der app-weite Standard, als Vorbelegung und Vergleich.
|
||||
defaults: {type: Object, default: () => ({})},
|
||||
charsets: {type: Array, default: () => ['auto', 'UTF-8', 'Windows-1252', 'ISO-8859-15']},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const FIELDS = [
|
||||
{key: 'payment_date', label: 'Zahlungsdatum', required: true, hint: 'Üblich: der Buchungstag.'},
|
||||
{key: 'purpose', label: 'Verwendungszweck', required: true},
|
||||
{key: 'amount', label: 'Betrag', required: true},
|
||||
{key: 'payer_name', label: 'Name des Zahlers', required: false},
|
||||
{key: 'payer_iban', label: 'IBAN des Zahlers', required: false, hint: 'Ohne diese Spalte bleiben die Erstattungsdaten leer.'},
|
||||
]
|
||||
|
||||
function parse(value) {
|
||||
if (!value) return null
|
||||
if (typeof value === 'object') return value
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function blank() {
|
||||
return {
|
||||
delimiter: ';',
|
||||
enclosure: '',
|
||||
charset: 'auto',
|
||||
has_header: true,
|
||||
date_format: 'd.m.Y',
|
||||
decimal_separator: ',',
|
||||
thousands_separator: '.',
|
||||
columns: {},
|
||||
}
|
||||
}
|
||||
|
||||
// Eine tiefe Kopie, damit das Bearbeiten nicht die angezeigten Standardwerte überschreibt.
|
||||
function fromDefaults() {
|
||||
return JSON.parse(JSON.stringify({...blank(), ...props.defaults}))
|
||||
}
|
||||
|
||||
const custom = ref(parse(props.modelValue) !== null)
|
||||
const form = ref(parse(props.modelValue) ?? fromDefaults())
|
||||
|
||||
watch(() => props.modelValue, (value) => {
|
||||
const parsed = parse(value)
|
||||
custom.value = parsed !== null
|
||||
form.value = parsed ?? fromDefaults()
|
||||
})
|
||||
|
||||
const shown = computed(() => (custom.value ? form.value : fromDefaults()))
|
||||
|
||||
const missingRequired = computed(
|
||||
() => FIELDS.filter(field => field.required && !(shown.value.columns ?? {})[field.key]).map(field => field.label),
|
||||
)
|
||||
|
||||
function useAppDefault() {
|
||||
custom.value = false
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
|
||||
function useOwnFormat() {
|
||||
custom.value = true
|
||||
form.value = fromDefaults()
|
||||
push()
|
||||
}
|
||||
|
||||
function push() {
|
||||
emit('update:modelValue', JSON.stringify(form.value))
|
||||
}
|
||||
|
||||
function setColumn(key, value) {
|
||||
form.value.columns = {...(form.value.columns ?? {})}
|
||||
if (value) {
|
||||
form.value.columns[key] = value
|
||||
} else {
|
||||
delete form.value.columns[key]
|
||||
}
|
||||
push()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ruleset">
|
||||
<label class="mode">
|
||||
<input type="radio" :checked="!custom" @change="useAppDefault"/>
|
||||
App-Standard verwenden
|
||||
</label>
|
||||
<label class="mode">
|
||||
<input type="radio" :checked="custom" @change="useOwnFormat"/>
|
||||
Eigenes Format für unsere Bank
|
||||
</label>
|
||||
|
||||
<p v-if="!custom" class="note">
|
||||
Es gilt der app-weite Standard. Bearbeitet ihr das Format, gilt ausschließlich eure
|
||||
Einstellung — der Standard wirkt dann nicht mehr nach.
|
||||
</p>
|
||||
|
||||
<table class="ruleset-table">
|
||||
<tr>
|
||||
<td>Trennzeichen</td>
|
||||
<td>
|
||||
<input type="text" class="form-input short" :disabled="!custom"
|
||||
:value="shown.delimiter" @input="form.delimiter = $event.target.value; push()"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Anführungszeichen</td>
|
||||
<td>
|
||||
<input type="text" class="form-input short" :disabled="!custom" placeholder="(keine)"
|
||||
:value="shown.enclosure" @input="form.enclosure = $event.target.value; push()"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Zeichensatz</td>
|
||||
<td>
|
||||
<select class="form-input" :disabled="!custom"
|
||||
:value="shown.charset" @change="form.charset = $event.target.value; push()">
|
||||
<option v-for="charset in charsets" :key="charset" :value="charset">
|
||||
{{ charset === 'auto' ? 'Automatisch erkennen' : charset }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Datumsformat</td>
|
||||
<td>
|
||||
<input type="text" class="form-input short" :disabled="!custom"
|
||||
:value="shown.date_format" @input="form.date_format = $event.target.value; push()"/>
|
||||
<span class="option-hint">PHP-Schreibweise, z. B. d.m.Y für 09.09.2026</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Dezimaltrenner</td>
|
||||
<td>
|
||||
<input type="text" class="form-input short" :disabled="!custom"
|
||||
:value="shown.decimal_separator" @input="form.decimal_separator = $event.target.value; push()"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tausendertrenner</td>
|
||||
<td>
|
||||
<input type="text" class="form-input short" :disabled="!custom" placeholder="(keiner)"
|
||||
:value="shown.thousands_separator" @input="form.thousands_separator = $event.target.value; push()"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4>Spalten der Datei</h4>
|
||||
<p class="note">
|
||||
Die Überschrift der jeweiligen Spalte, genau so wie sie in der ersten Zeile des Exports steht.
|
||||
</p>
|
||||
|
||||
<table class="ruleset-table">
|
||||
<tr v-for="field in FIELDS" :key="field.key">
|
||||
<td>{{ field.label }}<span v-if="field.required" class="required-marker">*</span></td>
|
||||
<td>
|
||||
<input type="text" class="form-input" :disabled="!custom"
|
||||
:value="(shown.columns ?? {})[field.key] ?? ''"
|
||||
@input="setColumn(field.key, $event.target.value)"/>
|
||||
<span v-if="field.hint" class="option-hint">{{ field.hint }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p v-if="custom && missingRequired.length" class="warning">
|
||||
Ohne {{ missingRequired.join(', ') }} lässt sich kein Kontoauszug einlesen.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ruleset {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.mode {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mode input {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 6px 0 10px 0;
|
||||
color: #6b7280;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ruleset-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.ruleset-table td {
|
||||
padding: 4px 6px 4px 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.ruleset-table td:first-child {
|
||||
width: 190px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-input.short {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 16px 0 0 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.option-hint {
|
||||
display: block;
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.required-marker {
|
||||
color: #ef4444;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.warning {
|
||||
margin: 10px 0 0 0;
|
||||
color: #991b1b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* App-weiter Standard für das Einlesen von Kontoauszügen (CSV-Export der Bank).
|
||||
*
|
||||
* Voreingestellt ist das Format der GLS Gemeinschaftsbank. Ein Tenant kann das Ruleset in der
|
||||
* Konfiguration des Zahlungsmoduls „Überweisung" überschreiben (Option `statement_ruleset`); ist dort
|
||||
* etwas hinterlegt, gilt ausschließlich dieser Override -- es wird nicht feldweise gemischt.
|
||||
*
|
||||
* `columns` bildet die fachlichen Felder auf die Spaltenüberschriften der Bank ab. Gelesen wird über
|
||||
* die Kopfzeile, nicht über Spaltenpositionen: eine Bank, die ihre Spalten umsortiert, bleibt lesbar.
|
||||
*/
|
||||
return [
|
||||
'default_ruleset' => [
|
||||
// Trennzeichen der Spalten.
|
||||
'delimiter' => ';',
|
||||
|
||||
// Anführungszeichen um die Werte. Leer = die Bank setzt keine (so die GLS).
|
||||
'enclosure' => '',
|
||||
|
||||
// Zeichensatz der Datei: 'auto' | 'UTF-8' | 'Windows-1252' | 'ISO-8859-15'.
|
||||
'charset' => 'Windows-1252',
|
||||
|
||||
// Erste Zeile enthält die Spaltenüberschriften.
|
||||
'has_header' => true,
|
||||
|
||||
'date_format' => 'd.m.Y',
|
||||
'decimal_separator' => ',',
|
||||
'thousands_separator' => '.',
|
||||
|
||||
/*
|
||||
* Pflicht sind payment_date, purpose und amount -- ohne sie lässt sich eine Zahlung weder
|
||||
* datieren noch zuordnen noch buchen. payer_name und payer_iban sind optional; fehlen sie,
|
||||
* funktioniert der Import, nur die Erstattungsdaten bleiben leer.
|
||||
*
|
||||
* payment_date liest bewusst den Buchungstag: das ist der Tag, an dem das Geld ankam. Keine
|
||||
* Spalte des Exports sagt, wann der Auftrag abgeschickt wurde -- der Buchungstag kommt dem am
|
||||
* nächsten. Eine Bank ohne diese Spalte lässt sich hier auf ihre Valuta-Spalte umbiegen.
|
||||
*/
|
||||
'columns' => [
|
||||
'payment_date' => 'Buchungstag',
|
||||
'purpose' => 'Verwendungszweck',
|
||||
'amount' => 'Betrag',
|
||||
'payer_name' => 'Name Zahlungsbeteiligter',
|
||||
'payer_iban' => 'IBAN Zahlungsbeteiligter',
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Zwei Spuren, die der Zahlungsimport aus dem Kontoauszug hinterlässt.
|
||||
*
|
||||
* `refund_data` -- ob wir wissen, von welchem Konto der Beitrag kam. Erstattet wird ausschließlich auf
|
||||
* dieses Konto; bisher muss die teilnehmende Person das selbst zusichern, weil die App es nicht weiß.
|
||||
* Die IBAN und der Kontoinhaber selbst liegen in `payment_options` (die Überweisung weiß, wie ihre
|
||||
* Felder heißen), hier steht nur das Signal „vorhanden".
|
||||
*
|
||||
* `last_payment_date` -- der Buchungstag der zuletzt eingebuchten Zahlung. Er ist das Wasserzeichen
|
||||
* gegen Doppelbuchung: Wird derselbe Auszug ein zweites Mal hochgeladen, fallen alle Umsätze heraus,
|
||||
* die älter sind als die letzte erfasste Zahlung dieser Person.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('event_participants', function (Blueprint $table) {
|
||||
$table->boolean('refund_data')->default(false)->after('payment_options');
|
||||
$table->date('last_payment_date')->nullable()->after('refund_data');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('event_participants', function (Blueprint $table) {
|
||||
$table->dropColumn(['refund_data', 'last_payment_date']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,343 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Domains\Event\Actions\BookBankStatementPayments\BookBankStatementPaymentsCommand;
|
||||
use App\Domains\Event\Actions\BookBankStatementPayments\BookBankStatementPaymentsRequest;
|
||||
use App\Domains\Event\Actions\ParseBankStatement\ParseBankStatementCommand;
|
||||
use App\Domains\Event\Actions\ParseBankStatement\ParseBankStatementRequest;
|
||||
use App\Enumerations\EatingHabit;
|
||||
use App\Enumerations\EfzStatus;
|
||||
use App\Enumerations\FirstAidPermission;
|
||||
use App\Enumerations\ParticipationType;
|
||||
use App\Enumerations\SwimmingPermission;
|
||||
use App\Mail\ParticipantPaymentMails\ParticipantPaymentMissingPaymentMail;
|
||||
use App\Mail\ParticipantPaymentMails\ParticipantPaymentPaidMail;
|
||||
use App\Models\AvailablePaymentMethod;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventParticipant;
|
||||
use App\Models\PaymentMethod;
|
||||
use App\Models\Tenant;
|
||||
use App\RelationModels\EventPaymentMethods;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Der Zahlungsimport von der hochgeladenen Datei bis zur gebuchten Zahlung.
|
||||
*/
|
||||
class BankStatementImportTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Tenant $tenant;
|
||||
private Event $event;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Mail::fake();
|
||||
|
||||
$this->tenant = Tenant::create([
|
||||
'slug' => 'tv', 'name' => 'Test', 'email' => 't@example.com', 'email_finance' => 'f@example.com',
|
||||
'url' => 'test.local', 'account_name' => 'Test e.V.', 'account_iban' => 'DE00', 'account_bic' => 'XY',
|
||||
'city' => 'Stadt', 'postcode' => '00000', 'is_active_local_group' => true, 'has_active_instance' => true,
|
||||
]);
|
||||
app()->instance('tenant', $this->tenant);
|
||||
|
||||
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
|
||||
|
||||
DB::table('participation_fee_types')->insert(['slug' => 'FIXED', 'name' => 'Fixed', 'created_at' => now(), 'updated_at' => now()]);
|
||||
ParticipationType::create(['slug' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, 'name' => 'Teilnehmende']);
|
||||
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_CHECKED, 'name' => 'Nicht geprüft']);
|
||||
SwimmingPermission::create(['slug' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, 'name' => 'Darf baden', 'short' => 'Erteilt']);
|
||||
FirstAidPermission::create(['slug' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED, 'name' => 'Zugestimmt', 'description' => '']);
|
||||
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_OMNIVOR, 'name' => 'Omnivor']);
|
||||
|
||||
$this->event = Event::create([
|
||||
'tenant' => $this->tenant->slug, 'name' => 'Sommerlager', 'identifier' => 'evt-1', 'location' => 'Ort',
|
||||
'postal_code' => '00000', 'email' => 'e@example.com', 'start_date' => '2026-08-01', 'end_date' => '2026-08-05',
|
||||
'early_bird_end' => '2026-07-01', 'registration_final_end' => '2026-07-20', 'early_bird_end_amount_increase' => 0,
|
||||
'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'FIXED',
|
||||
'pay_per_day' => false, 'pay_direct' => false,
|
||||
]);
|
||||
|
||||
$configuration = ['account_owner' => 'Kasse', 'iban' => 'DE02120300000000202051'];
|
||||
|
||||
AvailablePaymentMethod::create([
|
||||
'tenant' => $this->tenant->slug, 'slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
|
||||
'name' => 'Überweisung', 'active' => true, 'configuration' => $configuration,
|
||||
]);
|
||||
|
||||
EventPaymentMethods::create([
|
||||
'event_id' => $this->event->id,
|
||||
'slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
|
||||
'configuration' => $configuration,
|
||||
]);
|
||||
}
|
||||
|
||||
private function participant(
|
||||
string $identifier,
|
||||
string $firstname,
|
||||
string $lastname,
|
||||
float $amount = 120.0,
|
||||
float $amountPaid = 0.0,
|
||||
?string $lastPaymentDate = null,
|
||||
?string $unregisteredAt = null,
|
||||
): EventParticipant {
|
||||
return EventParticipant::create([
|
||||
'tenant' => $this->tenant->slug, 'event_id' => $this->event->id, 'identifier' => $identifier,
|
||||
'firstname' => $firstname, 'lastname' => $lastname,
|
||||
'participation_type' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
|
||||
'birthday' => '2000-01-01', 'address_1' => 'Weg 1', 'postcode' => '00000', 'city' => 'Stadt',
|
||||
'email_1' => strtolower($firstname) . '@example.com', 'phone_1' => '123',
|
||||
'arrival_date' => '2026-08-01', 'departure_date' => '2026-08-05',
|
||||
'arrival_eating' => 0, 'departure_eating' => 0,
|
||||
'amount' => $amount, 'amount_paid' => $amountPaid,
|
||||
'payment_purpose' => "Sommerlager - Beitrag {$firstname} {$lastname}",
|
||||
'efz_status' => EfzStatus::EFZ_STATUS_NOT_CHECKED,
|
||||
'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
|
||||
'swimming_permission' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED,
|
||||
'first_aid_permission' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED,
|
||||
'eating_habit' => EatingHabit::EATING_HABIT_OMNIVOR,
|
||||
'last_payment_date' => $lastPaymentDate,
|
||||
'unregistered_at' => $unregisteredAt,
|
||||
]);
|
||||
}
|
||||
|
||||
private function upload(string $csv, string $name = 'Umsaetze.csv'): UploadedFile
|
||||
{
|
||||
return UploadedFile::fake()->createWithContent($name, $csv);
|
||||
}
|
||||
|
||||
private function parse(UploadedFile $file): \App\Domains\Event\Actions\ParseBankStatement\ParseBankStatementResponse
|
||||
{
|
||||
return new ParseBankStatementCommand(new ParseBankStatementRequest(
|
||||
event: $this->event,
|
||||
file: $file,
|
||||
configuration: (new \App\Repositories\PaymentMethodRepository())
|
||||
->tenantConfiguration(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION),
|
||||
))->execute();
|
||||
}
|
||||
|
||||
public function test_parse_suggests_the_matching_participant(): void
|
||||
{
|
||||
$this->participant('p-1', 'Max', 'Meier');
|
||||
$this->participant('p-2', 'Lena', 'Kunze');
|
||||
|
||||
$csv = "Buchungstag;Valutadatum;Name Zahlungsbeteiligter;IBAN Zahlungsbeteiligter;Verwendungszweck;Betrag\r\n"
|
||||
. "09.09.2026;09.09.2026;Max Meier;DE02120300000000202051;Sommerlager - Beitrag Max Meier;120,00\r\n"
|
||||
. "09.09.2026;09.09.2026;ACME GmbH;DE02120300000000202051;Rechnung 4711;60,00\r\n"
|
||||
// Belastung: keine Zahlung an die Aktion, taucht gar nicht erst auf.
|
||||
. "09.09.2026;09.09.2026;Zeltplatz;DE02120300000000202051;Miete;-300,00\r\n";
|
||||
|
||||
$response = $this->parse($this->upload($csv));
|
||||
|
||||
$this->assertTrue($response->success);
|
||||
$this->assertCount(2, $response->rows);
|
||||
$this->assertSame('p-1', $response->rows[0]['suggestedIdentifier']);
|
||||
$this->assertSame('sicher', $response->rows[0]['confidence']);
|
||||
$this->assertNull($response->rows[1]['suggestedIdentifier']);
|
||||
|
||||
// Die Auswahlliste trägt alle Anmeldungen, auch die ohne Vorschlag -- nach Nachname sortiert,
|
||||
// damit sich in einem Lager mit achtzig Namen etwas finden lässt.
|
||||
$this->assertSame(
|
||||
['Kunze, Lena', 'Meier, Max'],
|
||||
array_column($response->participants, 'name'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wer den Beitrag überwiesen und sich danach abgemeldet hat, steht trotzdem im Kontoauszug. Bliebe
|
||||
* die Anmeldung hier draußen, wäre der Eingang nicht zuordenbar und das Geld läge unbemerkt auf
|
||||
* dem Konto -- erstatten lässt sich erst, was erfasst ist.
|
||||
*/
|
||||
public function test_parse_includes_signed_off_participants(): void
|
||||
{
|
||||
$this->participant('p-1', 'Max', 'Meier', unregisteredAt: '2026-07-15');
|
||||
|
||||
$csv = "Buchungstag;Verwendungszweck;Betrag\r\n"
|
||||
. "09.09.2026;Sommerlager - Beitrag Max Meier;120,00\r\n";
|
||||
|
||||
$response = $this->parse($this->upload($csv));
|
||||
|
||||
$this->assertCount(1, $response->rows);
|
||||
$this->assertSame('p-1', $response->rows[0]['suggestedIdentifier']);
|
||||
|
||||
// Die Prüfansicht muss die Abmeldung zeigen, sonst bucht jemand blind.
|
||||
$this->assertTrue($response->participants[0]['isSignedOff']);
|
||||
$this->assertSame('15.07.2026', $response->participants[0]['signedOffAt']);
|
||||
}
|
||||
|
||||
public function test_booking_works_for_a_signed_off_participant(): void
|
||||
{
|
||||
$participant = $this->participant('p-1', 'Max', 'Meier', amount: 120.0, unregisteredAt: '2026-07-15');
|
||||
|
||||
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
|
||||
event: $this->event,
|
||||
bookings: [[
|
||||
'participantIdentifier' => 'p-1',
|
||||
'paymentDate' => '2026-09-09',
|
||||
'amount' => 120.0,
|
||||
'payerName' => 'Max Meier',
|
||||
'payerIban' => 'DE02120300000000202051',
|
||||
'purpose' => 'Sommerlager - Beitrag Max Meier',
|
||||
]],
|
||||
))->execute();
|
||||
|
||||
$this->assertSame(1, $response->booked);
|
||||
|
||||
$participant->refresh();
|
||||
$this->assertSame(120.0, $participant->amount_paid->getAmount());
|
||||
// Damit ist die Grundlage für die Erstattung da: Konto bekannt, Betrag erfasst.
|
||||
$this->assertTrue($participant->refund_data);
|
||||
$this->assertSame('DE02120300000000202051', $participant->payment_options['payer_iban']);
|
||||
}
|
||||
|
||||
public function test_parse_hides_payments_older_than_the_watermark(): void
|
||||
{
|
||||
$this->participant('p-1', 'Max', 'Meier', lastPaymentDate: '2026-09-09');
|
||||
|
||||
$csv = "Buchungstag;Verwendungszweck;Betrag\r\n"
|
||||
// Älter als die zuletzt erfasste Zahlung -- bereits gebucht.
|
||||
. "05.09.2026;Sommerlager - Beitrag Max Meier;40,00\r\n"
|
||||
// Gleicher Tag: eine zweite Rate am selben Tag soll durchkommen.
|
||||
. "09.09.2026;Sommerlager - Beitrag Max Meier;30,00\r\n";
|
||||
|
||||
$response = $this->parse($this->upload($csv));
|
||||
|
||||
$this->assertCount(1, $response->rows);
|
||||
$this->assertSame(1, $response->skippedOlderThanWatermark);
|
||||
$this->assertSame(30.0, $response->rows[0]['amount']);
|
||||
}
|
||||
|
||||
public function test_parse_reports_a_wrong_file(): void
|
||||
{
|
||||
$response = $this->parse($this->upload("Spalte A;Spalte B\r\n1;2\r\n"));
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertStringContainsString('Buchungstag', $response->message);
|
||||
}
|
||||
|
||||
public function test_parse_rejects_a_non_csv_upload(): void
|
||||
{
|
||||
$response = $this->parse($this->upload('irgendwas', 'auszug.pdf'));
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertStringContainsString('CSV', $response->message);
|
||||
}
|
||||
|
||||
public function test_booking_adds_to_the_paid_amount_and_records_the_account(): void
|
||||
{
|
||||
$participant = $this->participant('p-1', 'Max', 'Meier', amount: 120.0, amountPaid: 40.0);
|
||||
|
||||
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
|
||||
event: $this->event,
|
||||
bookings: [[
|
||||
'participantIdentifier' => 'p-1',
|
||||
'paymentDate' => '2026-09-09',
|
||||
'amount' => 80.0,
|
||||
'payerName' => 'Max Meier',
|
||||
'payerIban' => 'DE02 1203 0000 0000 2020 51',
|
||||
'purpose' => 'Sommerlager - Beitrag Max Meier',
|
||||
]],
|
||||
))->execute();
|
||||
|
||||
$this->assertTrue($response->success);
|
||||
$this->assertSame(1, $response->booked);
|
||||
|
||||
$participant->refresh();
|
||||
// Aufaddiert, nicht überschrieben: 40 + 80.
|
||||
$this->assertSame(120.0, $participant->amount_paid->getAmount());
|
||||
$this->assertTrue($participant->refund_data);
|
||||
$this->assertSame('DE02120300000000202051', $participant->payment_options['payer_iban']);
|
||||
$this->assertSame('Max Meier', $participant->payment_options['payer_account_owner']);
|
||||
$this->assertSame('2026-09-09', $participant->last_payment_date->format('Y-m-d'));
|
||||
|
||||
Mail::assertSent(ParticipantPaymentPaidMail::class);
|
||||
}
|
||||
|
||||
public function test_booking_a_partial_payment_asks_for_the_rest(): void
|
||||
{
|
||||
$this->participant('p-1', 'Max', 'Meier', amount: 120.0);
|
||||
|
||||
new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
|
||||
event: $this->event,
|
||||
bookings: [[
|
||||
'participantIdentifier' => 'p-1',
|
||||
'paymentDate' => '2026-09-09',
|
||||
'amount' => 50.0,
|
||||
'payerName' => 'Max Meier',
|
||||
'payerIban' => 'DE02120300000000202051',
|
||||
'purpose' => 'Anzahlung',
|
||||
]],
|
||||
))->execute();
|
||||
|
||||
Mail::assertSent(ParticipantPaymentMissingPaymentMail::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* In der Prüfansicht lässt sich eine Zeile von Hand einer anderen Person zuordnen -- deren
|
||||
* Wasserzeichen hat der erste Durchlauf nie gesehen. Deshalb wird serverseitig noch einmal geprüft.
|
||||
*/
|
||||
public function test_booking_skips_a_payment_older_than_the_watermark(): void
|
||||
{
|
||||
$participant = $this->participant('p-1', 'Max', 'Meier', amountPaid: 40.0, lastPaymentDate: '2026-09-09');
|
||||
|
||||
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
|
||||
event: $this->event,
|
||||
bookings: [[
|
||||
'participantIdentifier' => 'p-1',
|
||||
'paymentDate' => '2026-09-01',
|
||||
'amount' => 80.0,
|
||||
'payerName' => 'Max Meier',
|
||||
'payerIban' => 'DE02120300000000202051',
|
||||
'purpose' => 'Alte Zahlung',
|
||||
]],
|
||||
))->execute();
|
||||
|
||||
$this->assertSame(0, $response->booked);
|
||||
$this->assertSame(1, $response->skipped);
|
||||
|
||||
$participant->refresh();
|
||||
$this->assertSame(40.0, $participant->amount_paid->getAmount());
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
|
||||
/** Der Identifier kommt aus dem Browser -- eine fremde Anmeldung darf nicht gebucht werden. */
|
||||
public function test_booking_rejects_a_participant_from_another_event(): void
|
||||
{
|
||||
$otherEvent = Event::create([
|
||||
'tenant' => $this->tenant->slug, 'name' => 'Herbstfahrt', 'identifier' => 'evt-2', 'location' => 'Ort',
|
||||
'postal_code' => '00000', 'email' => 'e@example.com', 'start_date' => '2026-10-01', 'end_date' => '2026-10-05',
|
||||
'early_bird_end' => '2026-09-01', 'registration_final_end' => '2026-09-20', 'early_bird_end_amount_increase' => 0,
|
||||
'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'FIXED',
|
||||
'pay_per_day' => false, 'pay_direct' => false,
|
||||
]);
|
||||
|
||||
$foreign = $this->participant('p-1', 'Max', 'Meier');
|
||||
$foreign->event_id = $otherEvent->id;
|
||||
$foreign->save();
|
||||
|
||||
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
|
||||
event: $this->event,
|
||||
bookings: [[
|
||||
'participantIdentifier' => 'p-1',
|
||||
'paymentDate' => '2026-09-09',
|
||||
'amount' => 80.0,
|
||||
'payerName' => 'Max Meier',
|
||||
'payerIban' => 'DE02120300000000202051',
|
||||
'purpose' => 'Beitrag',
|
||||
]],
|
||||
))->execute();
|
||||
|
||||
$this->assertSame(0, $response->booked);
|
||||
$this->assertSame(1, $response->failed);
|
||||
|
||||
$foreign->refresh();
|
||||
$this->assertSame(0.0, $foreign->amount_paid->getAmount());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\EventPaymentModules\DTO\TransactionMatch;
|
||||
use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
|
||||
use App\Models\EventParticipant;
|
||||
use App\ValueObjects\Amount;
|
||||
use App\ValueObjects\BankTransaction;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Collection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Die drei zahlartspezifischen Entscheidungen des Überweisungs-Moduls: Was zählt, wem es gehört, was
|
||||
* nachgetragen wird. Läuft ohne Datenbank -- die Kandidaten werden hereingereicht, das Modul fragt
|
||||
* selbst nichts ab.
|
||||
*/
|
||||
class BankStatementMatchTest extends TestCase
|
||||
{
|
||||
private function participant(
|
||||
string $identifier,
|
||||
string $firstname,
|
||||
string $lastname,
|
||||
string $purpose = '',
|
||||
float $amount = 120.0,
|
||||
float $amountPaid = 0.0,
|
||||
array $paymentOptions = [],
|
||||
): EventParticipant {
|
||||
$participant = new EventParticipant();
|
||||
$participant->setRawAttributes([
|
||||
'identifier' => $identifier,
|
||||
'firstname' => $firstname,
|
||||
'lastname' => $lastname,
|
||||
'payment_purpose' => $purpose !== '' ? $purpose : "Sommerlager - Beitrag {$firstname} {$lastname}",
|
||||
], true);
|
||||
$participant->amount = new Amount($amount, 'Euro');
|
||||
$participant->amount_paid = new Amount($amountPaid, 'Euro');
|
||||
$participant->payment_options = $paymentOptions;
|
||||
|
||||
return $participant;
|
||||
}
|
||||
|
||||
private function transaction(
|
||||
string $purpose,
|
||||
float $amount = 120.0,
|
||||
string $payerName = '',
|
||||
string $payerIban = '',
|
||||
): BankTransaction {
|
||||
return new BankTransaction(
|
||||
paymentDate: CarbonImmutable::create(2026, 9, 9),
|
||||
amount: new Amount($amount, 'Euro'),
|
||||
purpose: $purpose,
|
||||
payerName: $payerName,
|
||||
payerIban: $payerIban,
|
||||
rowNumber: 2,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<int, EventParticipant> $participants */
|
||||
private function candidates(array $participants): Collection
|
||||
{
|
||||
return new Collection($participants);
|
||||
}
|
||||
|
||||
public function test_only_credits_are_relevant_for_a_transfer(): void
|
||||
{
|
||||
$module = new AccountTransferPaymentModule();
|
||||
|
||||
$this->assertTrue($module->isRelevantTransaction($this->transaction('Beitrag', 120.0)));
|
||||
$this->assertFalse($module->isRelevantTransaction($this->transaction('Erstattung', -120.0)));
|
||||
}
|
||||
|
||||
/** Regel 1: der beim Anmelden erzeugte Verwendungszweck steht unverändert im Auszug. */
|
||||
public function test_matches_the_generated_payment_purpose(): void
|
||||
{
|
||||
$module = new AccountTransferPaymentModule();
|
||||
$candidates = $this->candidates([
|
||||
$this->participant('a', 'Max', 'Meier'),
|
||||
$this->participant('b', 'Lena', 'Kunze'),
|
||||
]);
|
||||
|
||||
$match = $module->matchTransaction($this->transaction('SOMMERLAGER - BEITRAG MAX MEIER'), $candidates);
|
||||
|
||||
$this->assertNotNull($match);
|
||||
$this->assertSame('a', $match->participant->identifier);
|
||||
$this->assertSame(TransactionMatch::CONFIDENCE_CERTAIN, $match->confidence);
|
||||
}
|
||||
|
||||
/** Regel 2: abgetippter Zweck, aber beide Namen sind noch drin -- auch mit Umlaut. */
|
||||
public function test_matches_both_names_in_a_retyped_purpose(): void
|
||||
{
|
||||
$module = new AccountTransferPaymentModule();
|
||||
$candidates = $this->candidates([$this->participant('a', 'Jörg', 'Müller')]);
|
||||
|
||||
$match = $module->matchTransaction($this->transaction('Beitrag fuer Joerg Mueller, Lager'), $candidates);
|
||||
|
||||
$this->assertNotNull($match);
|
||||
$this->assertSame('a', $match->participant->identifier);
|
||||
}
|
||||
|
||||
/** Regel 3: Folgezahlung vom Konto, von dem schon einmal etwas kam. */
|
||||
public function test_matches_a_follow_up_payment_by_known_iban(): void
|
||||
{
|
||||
$module = new AccountTransferPaymentModule();
|
||||
$candidates = $this->candidates([
|
||||
$this->participant('a', 'Max', 'Meier', paymentOptions: ['payer_iban' => 'DE02120300000000202051']),
|
||||
$this->participant('b', 'Lena', 'Kunze'),
|
||||
]);
|
||||
|
||||
// Zweck sagt nichts, IBAN in der Schreibweise der Bank (mit Leerzeichen).
|
||||
$match = $module->matchTransaction(
|
||||
$this->transaction('Restzahlung', 60.0, payerIban: 'DE02 1203 0000 0000 2020 51'),
|
||||
$candidates,
|
||||
);
|
||||
|
||||
$this->assertNotNull($match);
|
||||
$this->assertSame('a', $match->participant->identifier);
|
||||
}
|
||||
|
||||
/** Regel 4: nur der Nachname im Zweck, aber der Betrag trifft den offenen Rest. */
|
||||
public function test_matches_lastname_plus_exact_open_amount(): void
|
||||
{
|
||||
$module = new AccountTransferPaymentModule();
|
||||
$candidates = $this->candidates([
|
||||
$this->participant('a', 'Max', 'Meier', amount: 120.0, amountPaid: 40.0),
|
||||
$this->participant('b', 'Lena', 'Kunze', amount: 120.0),
|
||||
]);
|
||||
|
||||
$match = $module->matchTransaction($this->transaction('Restbetrag Meier', 80.0), $candidates);
|
||||
|
||||
$this->assertNotNull($match);
|
||||
$this->assertSame('a', $match->participant->identifier);
|
||||
}
|
||||
|
||||
/** Regel 5: das Konto läuft auf den Namen -- trägt oft, bei Elternkonten aber nicht. */
|
||||
public function test_matches_payer_name_only_as_uncertain(): void
|
||||
{
|
||||
$module = new AccountTransferPaymentModule();
|
||||
$candidates = $this->candidates([$this->participant('a', 'Max', 'Meier')]);
|
||||
|
||||
$match = $module->matchTransaction($this->transaction('Ueberweisung', 99.0, payerName: 'Max Meier'), $candidates);
|
||||
|
||||
$this->assertNotNull($match);
|
||||
$this->assertSame(TransactionMatch::CONFIDENCE_UNCERTAIN, $match->confidence);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwei Namensgleiche: lieber kein Vorschlag als der falsche. In der Prüfansicht wäre die
|
||||
* Verwechslung nicht zu erkennen und würde durchgewinkt.
|
||||
*/
|
||||
public function test_no_suggestion_when_two_candidates_fit(): void
|
||||
{
|
||||
$module = new AccountTransferPaymentModule();
|
||||
$candidates = $this->candidates([
|
||||
$this->participant('a', 'Max', 'Meier'),
|
||||
$this->participant('b', 'Max', 'Meier'),
|
||||
]);
|
||||
|
||||
$this->assertNull($module->matchTransaction($this->transaction('Beitrag Max Meier'), $candidates));
|
||||
}
|
||||
|
||||
public function test_no_suggestion_when_nothing_fits(): void
|
||||
{
|
||||
$module = new AccountTransferPaymentModule();
|
||||
$candidates = $this->candidates([$this->participant('a', 'Max', 'Meier')]);
|
||||
|
||||
$this->assertNull($module->matchTransaction($this->transaction('Rechnung 4711', 60.0, payerName: 'ACME GmbH'), $candidates));
|
||||
}
|
||||
|
||||
public function test_records_the_payer_account(): void
|
||||
{
|
||||
$module = new AccountTransferPaymentModule();
|
||||
$participant = $this->participant('a', 'Max', 'Meier');
|
||||
|
||||
$module->recordTransaction(
|
||||
$participant,
|
||||
$this->transaction('Beitrag', 120.0, payerName: 'Max Meier', payerIban: 'DE02 1203 0000 0000 2020 51'),
|
||||
);
|
||||
|
||||
$this->assertSame('DE02120300000000202051', $participant->payment_options['payer_iban']);
|
||||
$this->assertSame('Max Meier', $participant->payment_options['payer_account_owner']);
|
||||
$this->assertTrue($participant->refund_data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manche Banken lassen den Namen bei Folgezahlungen leer. Der bereits bekannte Kontoinhaber darf
|
||||
* dadurch nicht verlorengehen -- ohne ihn ist die IBAN für die Erstattung wertlos.
|
||||
*/
|
||||
public function test_a_follow_up_without_payer_name_keeps_the_known_owner(): void
|
||||
{
|
||||
$module = new AccountTransferPaymentModule();
|
||||
$participant = $this->participant('a', 'Max', 'Meier', paymentOptions: [
|
||||
'payer_iban' => 'DE02120300000000202051',
|
||||
'payer_account_owner' => 'Max Meier',
|
||||
]);
|
||||
$participant->refund_data = true;
|
||||
|
||||
$module->recordTransaction(
|
||||
$participant,
|
||||
$this->transaction('Restzahlung', 60.0, payerName: '', payerIban: 'DE02120300000000202051'),
|
||||
);
|
||||
|
||||
$this->assertSame('Max Meier', $participant->payment_options['payer_account_owner']);
|
||||
$this->assertTrue($participant->refund_data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine IBAN mit Zahlendreher besteht die Prüfziffer nicht und wird nicht übernommen -- sonst
|
||||
* ginge die Erstattung später an eine fremde Person.
|
||||
*/
|
||||
public function test_ignores_an_invalid_iban(): void
|
||||
{
|
||||
$module = new AccountTransferPaymentModule();
|
||||
$participant = $this->participant('a', 'Max', 'Meier');
|
||||
|
||||
$module->recordTransaction(
|
||||
$participant,
|
||||
$this->transaction('Beitrag', 120.0, payerName: 'Max Meier', payerIban: 'DE02120300000000202015'),
|
||||
);
|
||||
|
||||
$this->assertSame([], $participant->payment_options);
|
||||
$this->assertNotTrue($participant->refund_data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Providers\BankStatementParseProvider;
|
||||
use App\Support\BankStatementParseException;
|
||||
use App\ValueObjects\BankStatementRuleset;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Der Parser -- zahlartneutral und ohne Framework. Getestet werden die Formatfallen, an denen ein
|
||||
* Bank-Export scheitert: Zeichensatz, Trennzeichen, Zahlformat, fehlende Spalten.
|
||||
*/
|
||||
class BankStatementParseTest extends TestCase
|
||||
{
|
||||
private function ruleset(array $overrides = []): BankStatementRuleset
|
||||
{
|
||||
$base = [
|
||||
'delimiter' => ';',
|
||||
'enclosure' => '',
|
||||
'charset' => 'Windows-1252',
|
||||
'has_header' => true,
|
||||
'date_format' => 'd.m.Y',
|
||||
'decimal_separator' => ',',
|
||||
'thousands_separator' => '.',
|
||||
'columns' => [
|
||||
'payment_date' => 'Buchungstag',
|
||||
'purpose' => 'Verwendungszweck',
|
||||
'amount' => 'Betrag',
|
||||
'payer_name' => 'Name Zahlungsbeteiligter',
|
||||
'payer_iban' => 'IBAN Zahlungsbeteiligter',
|
||||
],
|
||||
];
|
||||
|
||||
return new BankStatementRuleset(
|
||||
delimiter: $overrides['delimiter'] ?? $base['delimiter'],
|
||||
enclosure: $overrides['enclosure'] ?? $base['enclosure'],
|
||||
charset: $overrides['charset'] ?? $base['charset'],
|
||||
hasHeader: $overrides['has_header'] ?? $base['has_header'],
|
||||
dateFormat: $overrides['date_format'] ?? $base['date_format'],
|
||||
decimalSeparator: $overrides['decimal_separator'] ?? $base['decimal_separator'],
|
||||
thousandsSeparator: $overrides['thousands_separator'] ?? $base['thousands_separator'],
|
||||
columns: $overrides['columns'] ?? $base['columns'],
|
||||
);
|
||||
}
|
||||
|
||||
/** Das Format, das die GLS heute liefert: Semikolon, keine Anführungszeichen, CRLF. */
|
||||
public function test_reads_the_semicolon_export_without_enclosures(): void
|
||||
{
|
||||
$csv = "Buchungstag;Valutadatum;Name Zahlungsbeteiligter;IBAN Zahlungsbeteiligter;Verwendungszweck;Betrag\r\n"
|
||||
. "09.09.2026;09.09.2026;Max Meier;DE02120300000000202051;Sommerlager - Beitrag Max Meier;120,00\r\n";
|
||||
|
||||
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset());
|
||||
|
||||
$this->assertCount(1, $transactions);
|
||||
$this->assertSame('2026-09-09', $transactions[0]->paymentDate->format('Y-m-d'));
|
||||
$this->assertSame(120.0, $transactions[0]->amount->getAmount());
|
||||
$this->assertSame('Max Meier', $transactions[0]->payerName);
|
||||
$this->assertSame('DE02120300000000202051', $transactions[0]->payerIban);
|
||||
$this->assertSame('Sommerlager - Beitrag Max Meier', $transactions[0]->purpose);
|
||||
// Kopfzeile ist Zeile 1.
|
||||
$this->assertSame(2, $transactions[0]->rowNumber);
|
||||
}
|
||||
|
||||
public function test_reads_comma_separated_export_with_enclosures(): void
|
||||
{
|
||||
$csv = "\"Buchungstag\",\"Verwendungszweck\",\"Betrag\"\n"
|
||||
. "\"09.09.2026\",\"Beitrag, erste Rate\",\"85,50\"\n";
|
||||
|
||||
$transactions = new BankStatementParseProvider()->parse(
|
||||
$csv,
|
||||
$this->ruleset([
|
||||
'delimiter' => ',',
|
||||
'enclosure' => '"',
|
||||
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
|
||||
]),
|
||||
);
|
||||
|
||||
$this->assertCount(1, $transactions);
|
||||
// Das Komma im Zweck darf die Zeile nicht zerreißen.
|
||||
$this->assertSame('Beitrag, erste Rate', $transactions[0]->purpose);
|
||||
$this->assertSame(85.5, $transactions[0]->amount->getAmount());
|
||||
}
|
||||
|
||||
public function test_converts_windows_1252_umlauts(): void
|
||||
{
|
||||
$csv = mb_convert_encoding(
|
||||
"Buchungstag;Verwendungszweck;Betrag\r\n09.09.2026;Beitrag Jörg Müller;60,00\r\n",
|
||||
'Windows-1252',
|
||||
'UTF-8',
|
||||
);
|
||||
|
||||
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([
|
||||
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
|
||||
]));
|
||||
|
||||
$this->assertSame('Beitrag Jörg Müller', $transactions[0]->purpose);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Schutz gegen Doppel-Kodierung: Liefert die Bank entgegen der Einstellung UTF-8, darf aus
|
||||
* „Müller" kein „Müller" werden. Die Konvertierung würde technisch gelingen -- der Fehler fiele
|
||||
* erst auf der Rechnung auf.
|
||||
*/
|
||||
public function test_does_not_double_encode_utf8_content(): void
|
||||
{
|
||||
$csv = "Buchungstag;Verwendungszweck;Betrag\r\n09.09.2026;Beitrag Jörg Müller;60,00\r\n";
|
||||
|
||||
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([
|
||||
'charset' => 'Windows-1252',
|
||||
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
|
||||
]));
|
||||
|
||||
$this->assertSame('Beitrag Jörg Müller', $transactions[0]->purpose);
|
||||
}
|
||||
|
||||
public function test_parses_german_thousands_and_negative_amounts(): void
|
||||
{
|
||||
$csv = "Buchungstag;Verwendungszweck;Betrag\n"
|
||||
. "09.09.2026;Grosse Zahlung;1.234,56\n"
|
||||
. "10.09.2026;Rueckbuchung;-56,00\n";
|
||||
|
||||
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([
|
||||
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
|
||||
]));
|
||||
|
||||
// Der Parser filtert nicht -- ob eine Belastung zählt, entscheidet das Zahlungsmodul.
|
||||
$this->assertCount(2, $transactions);
|
||||
$this->assertSame(1234.56, $transactions[0]->amount->getAmount());
|
||||
$this->assertSame(-56.0, $transactions[1]->amount->getAmount());
|
||||
$this->assertTrue($transactions[0]->isCredit());
|
||||
$this->assertFalse($transactions[1]->isCredit());
|
||||
}
|
||||
|
||||
/** Summenzeilen und Fußnoten sind keine Umsätze und dürfen den Import nicht abbrechen. */
|
||||
public function test_skips_rows_without_date_or_amount(): void
|
||||
{
|
||||
$csv = "Buchungstag;Verwendungszweck;Betrag\n"
|
||||
. "09.09.2026;Beitrag;120,00\n"
|
||||
. ";Summe;;\n"
|
||||
. "10.09.2026;Ohne Betrag;\n";
|
||||
|
||||
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([
|
||||
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
|
||||
]));
|
||||
|
||||
$this->assertCount(1, $transactions);
|
||||
}
|
||||
|
||||
/** Der Normalfall bei falsch eingestelltem Ruleset -- die Meldung muss die Spalte nennen. */
|
||||
public function test_names_the_missing_column(): void
|
||||
{
|
||||
$csv = "Buchungstag;Verwendungszweck\n09.09.2026;Beitrag\n";
|
||||
|
||||
$this->expectException(BankStatementParseException::class);
|
||||
$this->expectExceptionMessageMatches('/Betrag/');
|
||||
|
||||
new BankStatementParseProvider()->parse($csv, $this->ruleset([
|
||||
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
|
||||
]));
|
||||
}
|
||||
|
||||
public function test_rejects_a_ruleset_without_required_mapping(): void
|
||||
{
|
||||
$this->expectException(BankStatementParseException::class);
|
||||
$this->expectExceptionMessageMatches('/amount/');
|
||||
|
||||
new BankStatementParseProvider()->parse(
|
||||
"Buchungstag;Verwendungszweck\n",
|
||||
$this->ruleset(['columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck']]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
|
||||
use App\Models\PaymentMethod;
|
||||
use App\ValueObjects\BankStatementRuleset;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Wie sich der app-weite Standard aus config/bankStatement.php und ein Tenant-Override zueinander
|
||||
* verhalten -- und dass das Ruleset nicht in den Event-Snapshot rutscht.
|
||||
*
|
||||
* Braucht den Framework-Kontext (config()), aber keine Datenbank.
|
||||
*/
|
||||
class BankStatementRulesetTest extends TestCase
|
||||
{
|
||||
public function test_empty_configuration_falls_back_to_the_app_default(): void
|
||||
{
|
||||
$ruleset = new AccountTransferPaymentModule()->statementRuleset([]);
|
||||
|
||||
$this->assertSame(';', $ruleset->delimiter);
|
||||
$this->assertSame('', $ruleset->enclosure);
|
||||
$this->assertSame('Windows-1252', $ruleset->charset);
|
||||
$this->assertSame('Buchungstag', $ruleset->column('payment_date'));
|
||||
$this->assertSame([], $ruleset->missingRequiredColumns());
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Override gilt ganz oder gar nicht: Wer die Spalten seiner Bank einträgt, bekommt nicht
|
||||
* daneben noch die Spaltennamen der GLS untergeschoben und sucht den Fehler an der falschen Stelle.
|
||||
*/
|
||||
public function test_a_tenant_override_replaces_the_column_mapping_entirely(): void
|
||||
{
|
||||
$ruleset = new AccountTransferPaymentModule()->statementRuleset([
|
||||
AccountTransferPaymentModule::OPTION_STATEMENT_RULESET => [
|
||||
'delimiter' => ',',
|
||||
'enclosure' => '"',
|
||||
'columns' => [
|
||||
'payment_date' => 'Datum',
|
||||
'purpose' => 'Zweck',
|
||||
'amount' => 'Summe',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame(',', $ruleset->delimiter);
|
||||
$this->assertSame('"', $ruleset->enclosure);
|
||||
$this->assertSame('Datum', $ruleset->column('payment_date'));
|
||||
// Nicht aus dem App-Standard ergänzt:
|
||||
$this->assertNull($ruleset->column('payer_iban'));
|
||||
}
|
||||
|
||||
/** Das Frontend schickt das Ruleset als JSON-String durch das generische Options-Formular. */
|
||||
public function test_accepts_the_override_as_json_string(): void
|
||||
{
|
||||
$ruleset = new AccountTransferPaymentModule()->statementRuleset([
|
||||
AccountTransferPaymentModule::OPTION_STATEMENT_RULESET => json_encode([
|
||||
'delimiter' => "\t",
|
||||
'columns' => ['payment_date' => 'Datum', 'purpose' => 'Zweck', 'amount' => 'Summe'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->assertSame("\t", $ruleset->delimiter);
|
||||
$this->assertSame('Datum', $ruleset->column('payment_date'));
|
||||
}
|
||||
|
||||
public function test_reports_a_missing_required_column(): void
|
||||
{
|
||||
$ruleset = BankStatementRuleset::fromConfiguration([
|
||||
'columns' => ['payment_date' => 'Datum', 'purpose' => 'Zweck'],
|
||||
]);
|
||||
|
||||
$this->assertSame(['amount'], $ruleset->missingRequiredColumns());
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Kontoauszug-Format beschreibt die Bank, nicht die Zusage an die Teilnehmenden. Im
|
||||
* Event-Snapshot eingefroren ließe sich nach einem Bankwechsel für laufende Aktionen nichts mehr
|
||||
* importieren -- IBAN und Kontoinhaber frieren dagegen weiterhin pro Aktion ein.
|
||||
*/
|
||||
public function test_the_ruleset_is_stripped_from_the_event_snapshot(): void
|
||||
{
|
||||
$configuration = [
|
||||
'account_owner' => 'Kasse',
|
||||
'iban' => 'DE02120300000000202051',
|
||||
AccountTransferPaymentModule::OPTION_STATEMENT_RULESET => ['delimiter' => ','],
|
||||
];
|
||||
|
||||
$snapshot = PaymentMethod::stripTenantScopedOptions(
|
||||
PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
|
||||
$configuration,
|
||||
);
|
||||
|
||||
$this->assertArrayNotHasKey(AccountTransferPaymentModule::OPTION_STATEMENT_RULESET, $snapshot);
|
||||
$this->assertSame('Kasse', $snapshot['account_owner']);
|
||||
$this->assertSame('DE02120300000000202051', $snapshot['iban']);
|
||||
}
|
||||
|
||||
public function test_the_ruleset_option_is_the_only_tenant_scoped_one(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
[AccountTransferPaymentModule::OPTION_STATEMENT_RULESET],
|
||||
new AccountTransferPaymentModule()->tenantScopedOptionKeys(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -52,14 +52,33 @@ class EventPaymentModuleRegistryTest extends TestCase
|
||||
|
||||
public function test_participant_options_per_module(): void
|
||||
{
|
||||
// Überweisung: keine payer-seitigen Eingaben.
|
||||
$this->assertSame([], (new AccountTransferPaymentModule())->getParticipantOptions());
|
||||
// Überweisung: keine payer-seitigen Eingaben im Anmeldeformular.
|
||||
$this->assertSame([], (new AccountTransferPaymentModule())->participantInputOptions());
|
||||
|
||||
// Sonstiges (Barzahlung vor Ort): ebenfalls keine payer-seitigen Eingaben -- die Zahlungsart
|
||||
// beschreibt sich allein über den vom Veranstalter gepflegten Freitext.
|
||||
$this->assertSame([], (new UndefinedPaymentModule())->getParticipantOptions());
|
||||
}
|
||||
|
||||
public function test_system_participant_options_stay_in_the_schema(): void
|
||||
{
|
||||
$module = new AccountTransferPaymentModule();
|
||||
|
||||
// Die Zahler-Konto-Felder trägt der Zahlungsimport nach, gefragt wird beim Anmelden nicht
|
||||
// danach. Im Schema müssen sie trotzdem stehen -- sonst wirft sanitizeParticipantOptions()
|
||||
// sie beim nächsten Durchlauf als unbekannte Schlüssel weg.
|
||||
$names = array_column($module->getParticipantOptions(), 'name');
|
||||
$this->assertSame(['payer_account_owner', 'payer_iban'], $names);
|
||||
|
||||
$this->assertSame(
|
||||
['payer_iban' => 'DE02120300000000202051'],
|
||||
$module->sanitizeParticipantOptions(['payer_iban' => 'DE02120300000000202051', 'evil' => 'x'])
|
||||
);
|
||||
|
||||
// Kein Pflichtfeld -- eine Anmeldung ohne Zahlungseingang bleibt vollständig.
|
||||
$this->assertTrue($module->participantOptionsComplete([]));
|
||||
}
|
||||
|
||||
public function test_participant_option_helpers(): void
|
||||
{
|
||||
// Anonymes Modul mit einer Pflicht-Teilnehmereingabe.
|
||||
|
||||
Reference in New Issue
Block a user