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