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