Dev 4.9.0 #17

Merged
th.guenther merged 6 commits from dev-4.9.0 into main 2026-09-10 19:23:18 +02:00
38 changed files with 3308 additions and 22 deletions
Showing only changes of commit 651b6147bf - Show all commits
@@ -5,6 +5,7 @@ namespace App\Domains\Admin\Controllers;
use App\Models\AvailablePaymentMethod; use App\Models\AvailablePaymentMethod;
use App\Resources\AvailablePaymentMethodResource; use App\Resources\AvailablePaymentMethodResource;
use App\Scopes\CommonController; use App\Scopes\CommonController;
use App\ValueObjects\BankStatementRuleset;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -16,6 +17,10 @@ class TenantPaymentMethodsGetController extends CommonController
'paymentMethods' => AvailablePaymentMethod::all() 'paymentMethods' => AvailablePaymentMethod::all()
->map(fn (AvailablePaymentMethod $method) => new AvailablePaymentMethodResource($method)->toArray($request)), ->map(fn (AvailablePaymentMethod $method) => new AvailablePaymentMethodResource($method)->toArray($request)),
'saveEndpoint' => '/api/v1/admin/tenant/payment-methods', '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> <script setup>
import {computed, ref} from 'vue'; import {computed, ref} from 'vue';
import BankRulesetEditor from "../../../../Views/Components/BankRulesetEditor.vue";
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue"; import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
import TextEditor from "../../../../Views/Components/TextEditor.vue"; import TextEditor from "../../../../Views/Components/TextEditor.vue";
import RichSelectBox from "../../../../Views/Components/RichSelectBox.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]" <RichSelectBox v-if="option.type === 'icon'" v-model="form.configuration[option.name]"
:options="PAYMENT_METHOD_ICONS" placeholder="Symbol wählen…"/> :options="PAYMENT_METHOD_ICONS" placeholder="Symbol wählen…"/>
<TextEditor v-else-if="option.type === 'richtext'" v-model="form.configuration[option.name]"/> <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"/> <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> <span v-if="option.hint" class="option-hint">{{ option.hint }}</span>
</td> </td>
@@ -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;
}
}
@@ -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,
) {
}
}
@@ -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. // Bestehende Zuweisung: Config nur bei explizitem Override anpassen, sonst unverändert lassen.
if ($override !== null) { if ($override !== null) {
$row = $existing[$slug]; $row = $existing[$slug];
$row->configuration = PaymentMethod::sanitizeConfiguration($slug, $override); $row->configuration = $this->eventConfiguration($slug, $override);
$row->save(); $row->save();
} }
continue; continue;
@@ -75,8 +75,27 @@ class SetPaymentMethodsCommand
EventPaymentMethods::create([ EventPaymentMethods::create([
'event_id' => $event->id, 'event_id' => $event->id,
'slug' => $slug, '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,
]);
}
}
+5
View File
@@ -1,5 +1,7 @@
<?php <?php
use App\Domains\Event\Controllers\BankStatementBookController;
use App\Domains\Event\Controllers\BankStatementParseController;
use App\Domains\Event\Controllers\CreateController; use App\Domains\Event\Controllers\CreateController;
use App\Domains\Event\Controllers\DetailsController; use App\Domains\Event\Controllers\DetailsController;
use App\Domains\Event\Controllers\EventArchiveController; use App\Domains\Event\Controllers\EventArchiveController;
@@ -48,6 +50,9 @@ Route::prefix('api/v1')
Route::post('/payment-methods', [DetailsController::class, 'updatePaymentMethods']); Route::post('/payment-methods', [DetailsController::class, 'updatePaymentMethods']);
Route::post('/participation-options', [DetailsController::class, 'updateParticipationOptions']); Route::post('/participation-options', [DetailsController::class, 'updateParticipationOptions']);
Route::post('/event-addons', [DetailsController::class, 'updateEventAddons']); 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 ParticipationSummary from "./ParticipationSummary.vue";
import CommonSettings from "./CommonSettings.vue"; import CommonSettings from "./CommonSettings.vue";
import EventManagement from "./EventManagement.vue"; import EventManagement from "./EventManagement.vue";
import BankStatementImport from "./BankStatementImport.vue";
import Modal from "../../../../Views/Components/Modal.vue"; import Modal from "../../../../Views/Components/Modal.vue";
import MailCompose from "./MailCompose.vue"; import MailCompose from "./MailCompose.vue";
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue"; import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
@@ -21,6 +22,12 @@ const props = defineProps({
const displayData = ref('main'); const displayData = ref('main');
const showEventData = ref(false); 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() { async function showMain() {
@@ -117,9 +124,18 @@ async function showEventAddons() {
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/photo-permission-list'"> <a :href="'/event/details/' + props.data.event.identifier + '/pdf/photo-permission-list'">
<input type="button" value="Foto-Erlaubnis (PDF)" /> <input type="button" value="Foto-Erlaubnis (PDF)" />
</a><br/> </a><br/>
<input type="button" value="Zahlungseingänge einlesen" @click="showBankStatement = true" /><br/>
</div> </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" /> <ParticipationFees v-if="displayData === 'participationFees'" :event="dynamicProps.event" @close="showMain" />
<ParticipationOptions v-else-if="displayData === 'participationOptions'" :event="dynamicProps.event" <ParticipationOptions v-else-if="displayData === 'participationOptions'" :event="dynamicProps.event"
@close="showMain"/> @close="showMain"/>
@@ -158,6 +174,7 @@ async function showEventAddons() {
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/photo-permission-list'"> <a :href="'/event/details/' + props.data.event.identifier + '/pdf/photo-permission-list'">
<input type="button" value="Foto-Erlaubnis (PDF)" /> <input type="button" value="Foto-Erlaubnis (PDF)" />
</a><br/> </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" @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" class="deny-button" value="Letzte Mahnung senden" style="display: none" /><br/>
<input type="button" value="Rundmail senden" @click="mailToGroup" /><br/> <input type="button" value="Rundmail senden" @click="mailToGroup" /><br/>
@@ -26,6 +26,18 @@ const paymentMethodConfigurations = reactive({})
const showOptionsModal = ref(false) const showOptionsModal = ref(false)
const optionsMethod = ref(null) 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) { function openOptions(method) {
optionsMethod.value = method optionsMethod.value = method
showOptionsModal.value = true showOptionsModal.value = true
@@ -47,7 +59,7 @@ onMounted(async () => {
for (const method of availablePaymentMethods.value) { for (const method of availablePaymentMethods.value) {
const source = eventConfigBySlug[method.slug] ?? method.configuration ?? {} const source = eventConfigBySlug[method.slug] ?? method.configuration ?? {}
const config = {} const config = {}
for (const option of method.optionsSchema ?? []) { for (const option of eventOptions(method)) {
config[option.name] = source[option.name] ?? '' config[option.name] = source[option.name] ?? ''
} }
paymentMethodConfigurations[method.slug] = config paymentMethodConfigurations[method.slug] = config
@@ -358,7 +370,7 @@ onMounted(async () => {
paymentMethod.name paymentMethod.name
}}</label> }}</label>
<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" class="link"
style="padding-left: 15px; font-size: 10pt;" style="padding-left: 15px; font-size: 10pt;"
@click="openOptions(paymentMethod)" @click="openOptions(paymentMethod)"
@@ -381,7 +393,7 @@ onMounted(async () => {
width="700px" width="700px"
@close="showOptionsModal = false" @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> <label>{{ option.label }}<span v-if="option.required" class="required-marker">*</span></label>
<RichSelectBox v-if="option.type === 'icon'" <RichSelectBox v-if="option.type === 'icon'"
v-model="paymentMethodConfigurations[optionsMethod.slug][option.name]" v-model="paymentMethodConfigurations[optionsMethod.slug][option.name]"
@@ -69,6 +69,37 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule
return $this->allRequiredFilled($this->getOptions(), $config); 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 * @param array<string, mixed> $input
* @return array<string, mixed> * @return array<string, mixed>
@@ -84,6 +115,24 @@ abstract class AbstractEventPaymentModule implements EventPaymentModule
return $this->allRequiredFilled($this->getParticipantOptions(), $input); 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 * @param array<int, array{name: string, required?: bool}> $schema
* @return array<int, string> * @return array<int, string>
+42 -4
View File
@@ -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*`). - `DTO/` — geteilte Request/Response-DTOs je Operation (`DoPayment*`, `CreateInvoice*`, `RegistrationSummary*`).
- `EventPaymentModuleRegistry` — statische Map `slug → Modul-Instanz` (`forSlug()`, `all()`, `slugs()`). **Neue Module - `EventPaymentModuleRegistry` — statische Map `slug → Modul-Instanz` (`forSlug()`, `all()`, `slugs()`). **Neue Module
hier eintragen.** Kein Container-Binding. hier eintragen.** Kein Container-Binding.
- `ProvidesGiroCode`**Fähigkeits-Interface** (siehe unten). - `ProvidesGiroCode`, `ProvidesStatementRuleset`, `ReadsBankStatements`**Fähigkeits-Interfaces** (siehe unten).
## Kernregeln ## Kernregeln
@@ -32,6 +32,15 @@ Rechnung) liegt gekapselt in einem Modul pro Zahlungsart. Aufgelöst wird über
generische `SignUpForm/components/PaymentMethodInputs.vue` schema-getrieben. 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 - 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). (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`), - `defaultConfiguration()` je Modul liefert die Start-Config beim Anlegen der Tenant-Instanz (`CreateTenantAction`),
aktuell das Default-Symbol (`icon`): Überweisung `building-columns`, Sonstiges `coins`. 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 - **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, - 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.** `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 ## Anmelde-Zusammenfassung / Mail-Anzeige
- `registrationSummary(RegistrationSummaryRequest): RegistrationSummaryResponse` liefert den zahlungsspezifischen - `registrationSummary(RegistrationSummaryRequest): RegistrationSummaryResponse` liefert den zahlungsspezifischen
@@ -118,6 +152,10 @@ Live-Inbetriebnahme einmal gegen die Produktionsdatenbank ausführen — ersetzt
## Tests ## Tests
`tests/Unit/PaymentMethodOptionsTest`, `tests/Unit/EventPaymentModuleRegistryTest`, `tests/Unit/PaymentMethodOptionsTest`, `tests/Unit/EventPaymentModuleRegistryTest`,
`tests/Unit/RegistrationSummaryTest`, `tests/Feature/PaymentMethodConfigurationTest`, `tests/Unit/RegistrationSummaryTest`, `tests/Unit/BankStatementParseTest`, `tests/Unit/BankStatementMatchTest`,
`tests/Feature/EventParticipantPaymentSummaryTest`. Ausführung im Container (PHP 8.5): `tests/Unit/BankStatementRulesetTest`, `tests/Feature/PaymentMethodConfigurationTest`,
`docker exec mareike-mareike-app-1 php artisan test`. `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\RegistrationRenderContext;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest; use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse; use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
use App\EventPaymentModules\DTO\TransactionMatch;
use App\EventPaymentModules\ProvidesGiroCode; use App\EventPaymentModules\ProvidesGiroCode;
use App\EventPaymentModules\ProvidesStatementRuleset;
use App\EventPaymentModules\ReadsBankStatements;
use App\Models\EventParticipant; use App\Models\EventParticipant;
use App\Models\PaymentMethod; use App\Models\PaymentMethod;
use App\Providers\GiroCodeProvider; 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. * Ü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 public static function slug(): string
{ {
return PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION; return PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION;
@@ -40,18 +58,228 @@ class AccountTransferPaymentModule extends AbstractEventPaymentModule implements
['name' => 'bic', 'label' => 'BIC', 'type' => 'string', 'required' => false], ['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' => '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' => '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 public function registrationSummary(RegistrationSummaryRequest $request): RegistrationSummaryResponse
{ {
$participant = $request->participant; $participant = $request->participant;
$config = $request->configuration; $config = $request->configuration;
$amountLeft = clone $participant->amount; $amountLeft = $this->amountLeft($participant);
if ($participant->amount_paid !== null) {
$amountLeft->subtractAmount($participant->amount_paid);
}
$hasPaymentInformation = $amountLeft->getAmount() > 0; $hasPaymentInformation = $amountLeft->getAmount() > 0;
@@ -85,10 +313,7 @@ class AccountTransferPaymentModule extends AbstractEventPaymentModule implements
public function giroCode(EventParticipant $participant, array $configuration): ?string public function giroCode(EventParticipant $participant, array $configuration): ?string
{ {
$amountLeft = clone $participant->amount; $amountLeft = $this->amountLeft($participant);
if ($participant->amount_paid !== null) {
$amountLeft->subtractAmount($participant->amount_paid);
}
if ($amountLeft->getAmount() <= 0) { if ($amountLeft->getAmount() <= 0) {
return null; 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;
}
+4
View File
@@ -74,6 +74,8 @@ class EventParticipant extends InstancedModel
'payment_purpose', 'payment_purpose',
'payment_method', 'payment_method',
'payment_options', 'payment_options',
'refund_data',
'last_payment_date',
'efz_status', 'efz_status',
'unregistered_at', 'unregistered_at',
]; ];
@@ -94,6 +96,8 @@ class EventParticipant extends InstancedModel
'amount' => AmountCast::class, 'amount' => AmountCast::class,
'amount_paid' => AmountCast::class, 'amount_paid' => AmountCast::class,
'payment_options' => 'array', 'payment_options' => 'array',
'refund_data' => 'boolean',
'last_payment_date' => 'date',
'invoice_sequence' => 'integer', 'invoice_sequence' => 'integer',
'sibling_reduction' => 'boolean', 'sibling_reduction' => 'boolean',
+18 -2
View File
@@ -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}> * @return array<int, array{name: string, label: string, type: string, required: bool}>
*/ */
public static function participantOptionsFor(string $slug): array 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) ?? []; 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. * 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. * 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\Event;
use App\Models\EventParticipant; use App\Models\EventParticipant;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection;
class EventParticipantRepository { class EventParticipantRepository {
public function getForList(Event $event, Request $request, bool $signedOffParticipants = false) : array { public function getForList(Event $event, Request $request, bool $signedOffParticipants = false) : array {
@@ -39,6 +40,39 @@ class EventParticipantRepository {
return $participant; 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 { public function groupByLocalGroup(Event $event, Request $request, ?string $filter = null) : array {
$allParticipants = $this->getForList($event, $request); $allParticipants = $this->getForList($event, $request);
$participants = []; $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 ?? []);
}
}
+3
View File
@@ -13,6 +13,7 @@ use App\Repositories\EventRepository;
use App\Repositories\InvoiceRepository; use App\Repositories\InvoiceRepository;
use App\Repositories\PageTextRepository; use App\Repositories\PageTextRepository;
use App\Repositories\ParticipantRefundRepository; use App\Repositories\ParticipantRefundRepository;
use App\Repositories\PaymentMethodRepository;
use App\Repositories\UserRepository; use App\Repositories\UserRepository;
abstract class CommonController { abstract class CommonController {
@@ -27,6 +28,7 @@ abstract class CommonController {
protected EventParticipantRepository $eventParticipants; protected EventParticipantRepository $eventParticipants;
protected ParticipantRefundRepository $participantRefunds; protected ParticipantRefundRepository $participantRefunds;
protected EstimatesRepository $estimates; protected EstimatesRepository $estimates;
protected PaymentMethodRepository $paymentMethods;
protected AdminUserRepository $adminUsers; protected AdminUserRepository $adminUsers;
protected AdminTenantRepository $adminTenants; protected AdminTenantRepository $adminTenants;
@@ -40,6 +42,7 @@ abstract class CommonController {
$this->eventParticipants = new EventParticipantRepository(); $this->eventParticipants = new EventParticipantRepository();
$this->participantRefunds = new ParticipantRefundRepository(); $this->participantRefunds = new ParticipantRefundRepository();
$this->estimates = new EstimatesRepository(); $this->estimates = new EstimatesRepository();
$this->paymentMethods = new PaymentMethodRepository();
$this->adminUsers = new AdminUserRepository(); $this->adminUsers = new AdminUserRepository();
$this->adminTenants = new AdminTenantRepository(); $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
{
}
+139
View File
@@ -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,
];
}
}
+49
View File
@@ -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,
];
}
}
+254
View File
@@ -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.&nbsp;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>
+48
View File
@@ -0,0 +1,48 @@
<?php
/**
* App-weiter Standard für das Einlesen von Kontoauszügen (CSV-Export der Bank).
*
* Voreingestellt ist das Format der GLS Gemeinschaftsbank. Ein Tenant kann das Ruleset in der
* Konfiguration des Zahlungsmoduls „Überweisung" überschreiben (Option `statement_ruleset`); ist dort
* etwas hinterlegt, gilt ausschließlich dieser Override -- es wird nicht feldweise gemischt.
*
* `columns` bildet die fachlichen Felder auf die Spaltenüberschriften der Bank ab. Gelesen wird über
* die Kopfzeile, nicht über Spaltenpositionen: eine Bank, die ihre Spalten umsortiert, bleibt lesbar.
*/
return [
'default_ruleset' => [
// Trennzeichen der Spalten.
'delimiter' => ';',
// Anführungszeichen um die Werte. Leer = die Bank setzt keine (so die GLS).
'enclosure' => '',
// Zeichensatz der Datei: 'auto' | 'UTF-8' | 'Windows-1252' | 'ISO-8859-15'.
'charset' => 'Windows-1252',
// Erste Zeile enthält die Spaltenüberschriften.
'has_header' => true,
'date_format' => 'd.m.Y',
'decimal_separator' => ',',
'thousands_separator' => '.',
/*
* Pflicht sind payment_date, purpose und amount -- ohne sie lässt sich eine Zahlung weder
* datieren noch zuordnen noch buchen. payer_name und payer_iban sind optional; fehlen sie,
* funktioniert der Import, nur die Erstattungsdaten bleiben leer.
*
* payment_date liest bewusst den Buchungstag: das ist der Tag, an dem das Geld ankam. Keine
* Spalte des Exports sagt, wann der Auftrag abgeschickt wurde -- der Buchungstag kommt dem am
* nächsten. Eine Bank ohne diese Spalte lässt sich hier auf ihre Valuta-Spalte umbiegen.
*/
'columns' => [
'payment_date' => 'Buchungstag',
'purpose' => 'Verwendungszweck',
'amount' => 'Betrag',
'payer_name' => 'Name Zahlungsbeteiligter',
'payer_iban' => 'IBAN Zahlungsbeteiligter',
],
],
];
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Zwei Spuren, die der Zahlungsimport aus dem Kontoauszug hinterlässt.
*
* `refund_data` -- ob wir wissen, von welchem Konto der Beitrag kam. Erstattet wird ausschließlich auf
* dieses Konto; bisher muss die teilnehmende Person das selbst zusichern, weil die App es nicht weiß.
* Die IBAN und der Kontoinhaber selbst liegen in `payment_options` (die Überweisung weiß, wie ihre
* Felder heißen), hier steht nur das Signal „vorhanden".
*
* `last_payment_date` -- der Buchungstag der zuletzt eingebuchten Zahlung. Er ist das Wasserzeichen
* gegen Doppelbuchung: Wird derselbe Auszug ein zweites Mal hochgeladen, fallen alle Umsätze heraus,
* die älter sind als die letzte erfasste Zahlung dieser Person.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('event_participants', function (Blueprint $table) {
$table->boolean('refund_data')->default(false)->after('payment_options');
$table->date('last_payment_date')->nullable()->after('refund_data');
});
}
public function down(): void
{
Schema::table('event_participants', function (Blueprint $table) {
$table->dropColumn(['refund_data', 'last_payment_date']);
});
}
};
+343
View File
@@ -0,0 +1,343 @@
<?php
namespace Tests\Feature;
use App\Domains\Event\Actions\BookBankStatementPayments\BookBankStatementPaymentsCommand;
use App\Domains\Event\Actions\BookBankStatementPayments\BookBankStatementPaymentsRequest;
use App\Domains\Event\Actions\ParseBankStatement\ParseBankStatementCommand;
use App\Domains\Event\Actions\ParseBankStatement\ParseBankStatementRequest;
use App\Enumerations\EatingHabit;
use App\Enumerations\EfzStatus;
use App\Enumerations\FirstAidPermission;
use App\Enumerations\ParticipationType;
use App\Enumerations\SwimmingPermission;
use App\Mail\ParticipantPaymentMails\ParticipantPaymentMissingPaymentMail;
use App\Mail\ParticipantPaymentMails\ParticipantPaymentPaidMail;
use App\Models\AvailablePaymentMethod;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\RelationModels\EventPaymentMethods;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
/**
* Der Zahlungsimport von der hochgeladenen Datei bis zur gebuchten Zahlung.
*/
class BankStatementImportTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
private Event $event;
protected function setUp(): void
{
parent::setUp();
Mail::fake();
$this->tenant = Tenant::create([
'slug' => 'tv', 'name' => 'Test', 'email' => 't@example.com', 'email_finance' => 'f@example.com',
'url' => 'test.local', 'account_name' => 'Test e.V.', 'account_iban' => 'DE00', 'account_bic' => 'XY',
'city' => 'Stadt', 'postcode' => '00000', 'is_active_local_group' => true, 'has_active_instance' => true,
]);
app()->instance('tenant', $this->tenant);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
DB::table('participation_fee_types')->insert(['slug' => 'FIXED', 'name' => 'Fixed', 'created_at' => now(), 'updated_at' => now()]);
ParticipationType::create(['slug' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT, 'name' => 'Teilnehmende']);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_CHECKED, 'name' => 'Nicht geprüft']);
SwimmingPermission::create(['slug' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED, 'name' => 'Darf baden', 'short' => 'Erteilt']);
FirstAidPermission::create(['slug' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED, 'name' => 'Zugestimmt', 'description' => '']);
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_OMNIVOR, 'name' => 'Omnivor']);
$this->event = Event::create([
'tenant' => $this->tenant->slug, 'name' => 'Sommerlager', 'identifier' => 'evt-1', 'location' => 'Ort',
'postal_code' => '00000', 'email' => 'e@example.com', 'start_date' => '2026-08-01', 'end_date' => '2026-08-05',
'early_bird_end' => '2026-07-01', 'registration_final_end' => '2026-07-20', 'early_bird_end_amount_increase' => 0,
'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'FIXED',
'pay_per_day' => false, 'pay_direct' => false,
]);
$configuration = ['account_owner' => 'Kasse', 'iban' => 'DE02120300000000202051'];
AvailablePaymentMethod::create([
'tenant' => $this->tenant->slug, 'slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
'name' => 'Überweisung', 'active' => true, 'configuration' => $configuration,
]);
EventPaymentMethods::create([
'event_id' => $this->event->id,
'slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
'configuration' => $configuration,
]);
}
private function participant(
string $identifier,
string $firstname,
string $lastname,
float $amount = 120.0,
float $amountPaid = 0.0,
?string $lastPaymentDate = null,
?string $unregisteredAt = null,
): EventParticipant {
return EventParticipant::create([
'tenant' => $this->tenant->slug, 'event_id' => $this->event->id, 'identifier' => $identifier,
'firstname' => $firstname, 'lastname' => $lastname,
'participation_type' => ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
'birthday' => '2000-01-01', 'address_1' => 'Weg 1', 'postcode' => '00000', 'city' => 'Stadt',
'email_1' => strtolower($firstname) . '@example.com', 'phone_1' => '123',
'arrival_date' => '2026-08-01', 'departure_date' => '2026-08-05',
'arrival_eating' => 0, 'departure_eating' => 0,
'amount' => $amount, 'amount_paid' => $amountPaid,
'payment_purpose' => "Sommerlager - Beitrag {$firstname} {$lastname}",
'efz_status' => EfzStatus::EFZ_STATUS_NOT_CHECKED,
'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
'swimming_permission' => SwimmingPermission::SWIMMING_PERMISSION_ALLOWED,
'first_aid_permission' => FirstAidPermission::FIRST_AID_PERMISSION_ALLOWED,
'eating_habit' => EatingHabit::EATING_HABIT_OMNIVOR,
'last_payment_date' => $lastPaymentDate,
'unregistered_at' => $unregisteredAt,
]);
}
private function upload(string $csv, string $name = 'Umsaetze.csv'): UploadedFile
{
return UploadedFile::fake()->createWithContent($name, $csv);
}
private function parse(UploadedFile $file): \App\Domains\Event\Actions\ParseBankStatement\ParseBankStatementResponse
{
return new ParseBankStatementCommand(new ParseBankStatementRequest(
event: $this->event,
file: $file,
configuration: (new \App\Repositories\PaymentMethodRepository())
->tenantConfiguration(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION),
))->execute();
}
public function test_parse_suggests_the_matching_participant(): void
{
$this->participant('p-1', 'Max', 'Meier');
$this->participant('p-2', 'Lena', 'Kunze');
$csv = "Buchungstag;Valutadatum;Name Zahlungsbeteiligter;IBAN Zahlungsbeteiligter;Verwendungszweck;Betrag\r\n"
. "09.09.2026;09.09.2026;Max Meier;DE02120300000000202051;Sommerlager - Beitrag Max Meier;120,00\r\n"
. "09.09.2026;09.09.2026;ACME GmbH;DE02120300000000202051;Rechnung 4711;60,00\r\n"
// Belastung: keine Zahlung an die Aktion, taucht gar nicht erst auf.
. "09.09.2026;09.09.2026;Zeltplatz;DE02120300000000202051;Miete;-300,00\r\n";
$response = $this->parse($this->upload($csv));
$this->assertTrue($response->success);
$this->assertCount(2, $response->rows);
$this->assertSame('p-1', $response->rows[0]['suggestedIdentifier']);
$this->assertSame('sicher', $response->rows[0]['confidence']);
$this->assertNull($response->rows[1]['suggestedIdentifier']);
// Die Auswahlliste trägt alle Anmeldungen, auch die ohne Vorschlag -- nach Nachname sortiert,
// damit sich in einem Lager mit achtzig Namen etwas finden lässt.
$this->assertSame(
['Kunze, Lena', 'Meier, Max'],
array_column($response->participants, 'name'),
);
}
/**
* Wer den Beitrag überwiesen und sich danach abgemeldet hat, steht trotzdem im Kontoauszug. Bliebe
* die Anmeldung hier draußen, wäre der Eingang nicht zuordenbar und das Geld läge unbemerkt auf
* dem Konto -- erstatten lässt sich erst, was erfasst ist.
*/
public function test_parse_includes_signed_off_participants(): void
{
$this->participant('p-1', 'Max', 'Meier', unregisteredAt: '2026-07-15');
$csv = "Buchungstag;Verwendungszweck;Betrag\r\n"
. "09.09.2026;Sommerlager - Beitrag Max Meier;120,00\r\n";
$response = $this->parse($this->upload($csv));
$this->assertCount(1, $response->rows);
$this->assertSame('p-1', $response->rows[0]['suggestedIdentifier']);
// Die Prüfansicht muss die Abmeldung zeigen, sonst bucht jemand blind.
$this->assertTrue($response->participants[0]['isSignedOff']);
$this->assertSame('15.07.2026', $response->participants[0]['signedOffAt']);
}
public function test_booking_works_for_a_signed_off_participant(): void
{
$participant = $this->participant('p-1', 'Max', 'Meier', amount: 120.0, unregisteredAt: '2026-07-15');
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
event: $this->event,
bookings: [[
'participantIdentifier' => 'p-1',
'paymentDate' => '2026-09-09',
'amount' => 120.0,
'payerName' => 'Max Meier',
'payerIban' => 'DE02120300000000202051',
'purpose' => 'Sommerlager - Beitrag Max Meier',
]],
))->execute();
$this->assertSame(1, $response->booked);
$participant->refresh();
$this->assertSame(120.0, $participant->amount_paid->getAmount());
// Damit ist die Grundlage für die Erstattung da: Konto bekannt, Betrag erfasst.
$this->assertTrue($participant->refund_data);
$this->assertSame('DE02120300000000202051', $participant->payment_options['payer_iban']);
}
public function test_parse_hides_payments_older_than_the_watermark(): void
{
$this->participant('p-1', 'Max', 'Meier', lastPaymentDate: '2026-09-09');
$csv = "Buchungstag;Verwendungszweck;Betrag\r\n"
// Älter als die zuletzt erfasste Zahlung -- bereits gebucht.
. "05.09.2026;Sommerlager - Beitrag Max Meier;40,00\r\n"
// Gleicher Tag: eine zweite Rate am selben Tag soll durchkommen.
. "09.09.2026;Sommerlager - Beitrag Max Meier;30,00\r\n";
$response = $this->parse($this->upload($csv));
$this->assertCount(1, $response->rows);
$this->assertSame(1, $response->skippedOlderThanWatermark);
$this->assertSame(30.0, $response->rows[0]['amount']);
}
public function test_parse_reports_a_wrong_file(): void
{
$response = $this->parse($this->upload("Spalte A;Spalte B\r\n1;2\r\n"));
$this->assertFalse($response->success);
$this->assertStringContainsString('Buchungstag', $response->message);
}
public function test_parse_rejects_a_non_csv_upload(): void
{
$response = $this->parse($this->upload('irgendwas', 'auszug.pdf'));
$this->assertFalse($response->success);
$this->assertStringContainsString('CSV', $response->message);
}
public function test_booking_adds_to_the_paid_amount_and_records_the_account(): void
{
$participant = $this->participant('p-1', 'Max', 'Meier', amount: 120.0, amountPaid: 40.0);
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
event: $this->event,
bookings: [[
'participantIdentifier' => 'p-1',
'paymentDate' => '2026-09-09',
'amount' => 80.0,
'payerName' => 'Max Meier',
'payerIban' => 'DE02 1203 0000 0000 2020 51',
'purpose' => 'Sommerlager - Beitrag Max Meier',
]],
))->execute();
$this->assertTrue($response->success);
$this->assertSame(1, $response->booked);
$participant->refresh();
// Aufaddiert, nicht überschrieben: 40 + 80.
$this->assertSame(120.0, $participant->amount_paid->getAmount());
$this->assertTrue($participant->refund_data);
$this->assertSame('DE02120300000000202051', $participant->payment_options['payer_iban']);
$this->assertSame('Max Meier', $participant->payment_options['payer_account_owner']);
$this->assertSame('2026-09-09', $participant->last_payment_date->format('Y-m-d'));
Mail::assertSent(ParticipantPaymentPaidMail::class);
}
public function test_booking_a_partial_payment_asks_for_the_rest(): void
{
$this->participant('p-1', 'Max', 'Meier', amount: 120.0);
new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
event: $this->event,
bookings: [[
'participantIdentifier' => 'p-1',
'paymentDate' => '2026-09-09',
'amount' => 50.0,
'payerName' => 'Max Meier',
'payerIban' => 'DE02120300000000202051',
'purpose' => 'Anzahlung',
]],
))->execute();
Mail::assertSent(ParticipantPaymentMissingPaymentMail::class);
}
/**
* In der Prüfansicht lässt sich eine Zeile von Hand einer anderen Person zuordnen -- deren
* Wasserzeichen hat der erste Durchlauf nie gesehen. Deshalb wird serverseitig noch einmal geprüft.
*/
public function test_booking_skips_a_payment_older_than_the_watermark(): void
{
$participant = $this->participant('p-1', 'Max', 'Meier', amountPaid: 40.0, lastPaymentDate: '2026-09-09');
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
event: $this->event,
bookings: [[
'participantIdentifier' => 'p-1',
'paymentDate' => '2026-09-01',
'amount' => 80.0,
'payerName' => 'Max Meier',
'payerIban' => 'DE02120300000000202051',
'purpose' => 'Alte Zahlung',
]],
))->execute();
$this->assertSame(0, $response->booked);
$this->assertSame(1, $response->skipped);
$participant->refresh();
$this->assertSame(40.0, $participant->amount_paid->getAmount());
Mail::assertNothingSent();
}
/** Der Identifier kommt aus dem Browser -- eine fremde Anmeldung darf nicht gebucht werden. */
public function test_booking_rejects_a_participant_from_another_event(): void
{
$otherEvent = Event::create([
'tenant' => $this->tenant->slug, 'name' => 'Herbstfahrt', 'identifier' => 'evt-2', 'location' => 'Ort',
'postal_code' => '00000', 'email' => 'e@example.com', 'start_date' => '2026-10-01', 'end_date' => '2026-10-05',
'early_bird_end' => '2026-09-01', 'registration_final_end' => '2026-09-20', 'early_bird_end_amount_increase' => 0,
'account_owner' => 'Owner', 'account_iban' => 'DE00', 'participation_fee_type' => 'FIXED',
'pay_per_day' => false, 'pay_direct' => false,
]);
$foreign = $this->participant('p-1', 'Max', 'Meier');
$foreign->event_id = $otherEvent->id;
$foreign->save();
$response = new BookBankStatementPaymentsCommand(new BookBankStatementPaymentsRequest(
event: $this->event,
bookings: [[
'participantIdentifier' => 'p-1',
'paymentDate' => '2026-09-09',
'amount' => 80.0,
'payerName' => 'Max Meier',
'payerIban' => 'DE02120300000000202051',
'purpose' => 'Beitrag',
]],
))->execute();
$this->assertSame(0, $response->booked);
$this->assertSame(1, $response->failed);
$foreign->refresh();
$this->assertSame(0.0, $foreign->amount_paid->getAmount());
}
}
+225
View File
@@ -0,0 +1,225 @@
<?php
namespace Tests\Unit;
use App\EventPaymentModules\DTO\TransactionMatch;
use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
use App\Models\EventParticipant;
use App\ValueObjects\Amount;
use App\ValueObjects\BankTransaction;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use PHPUnit\Framework\TestCase;
/**
* Die drei zahlartspezifischen Entscheidungen des Überweisungs-Moduls: Was zählt, wem es gehört, was
* nachgetragen wird. Läuft ohne Datenbank -- die Kandidaten werden hereingereicht, das Modul fragt
* selbst nichts ab.
*/
class BankStatementMatchTest extends TestCase
{
private function participant(
string $identifier,
string $firstname,
string $lastname,
string $purpose = '',
float $amount = 120.0,
float $amountPaid = 0.0,
array $paymentOptions = [],
): EventParticipant {
$participant = new EventParticipant();
$participant->setRawAttributes([
'identifier' => $identifier,
'firstname' => $firstname,
'lastname' => $lastname,
'payment_purpose' => $purpose !== '' ? $purpose : "Sommerlager - Beitrag {$firstname} {$lastname}",
], true);
$participant->amount = new Amount($amount, 'Euro');
$participant->amount_paid = new Amount($amountPaid, 'Euro');
$participant->payment_options = $paymentOptions;
return $participant;
}
private function transaction(
string $purpose,
float $amount = 120.0,
string $payerName = '',
string $payerIban = '',
): BankTransaction {
return new BankTransaction(
paymentDate: CarbonImmutable::create(2026, 9, 9),
amount: new Amount($amount, 'Euro'),
purpose: $purpose,
payerName: $payerName,
payerIban: $payerIban,
rowNumber: 2,
);
}
/** @param array<int, EventParticipant> $participants */
private function candidates(array $participants): Collection
{
return new Collection($participants);
}
public function test_only_credits_are_relevant_for_a_transfer(): void
{
$module = new AccountTransferPaymentModule();
$this->assertTrue($module->isRelevantTransaction($this->transaction('Beitrag', 120.0)));
$this->assertFalse($module->isRelevantTransaction($this->transaction('Erstattung', -120.0)));
}
/** Regel 1: der beim Anmelden erzeugte Verwendungszweck steht unverändert im Auszug. */
public function test_matches_the_generated_payment_purpose(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([
$this->participant('a', 'Max', 'Meier'),
$this->participant('b', 'Lena', 'Kunze'),
]);
$match = $module->matchTransaction($this->transaction('SOMMERLAGER - BEITRAG MAX MEIER'), $candidates);
$this->assertNotNull($match);
$this->assertSame('a', $match->participant->identifier);
$this->assertSame(TransactionMatch::CONFIDENCE_CERTAIN, $match->confidence);
}
/** Regel 2: abgetippter Zweck, aber beide Namen sind noch drin -- auch mit Umlaut. */
public function test_matches_both_names_in_a_retyped_purpose(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([$this->participant('a', 'Jörg', 'Müller')]);
$match = $module->matchTransaction($this->transaction('Beitrag fuer Joerg Mueller, Lager'), $candidates);
$this->assertNotNull($match);
$this->assertSame('a', $match->participant->identifier);
}
/** Regel 3: Folgezahlung vom Konto, von dem schon einmal etwas kam. */
public function test_matches_a_follow_up_payment_by_known_iban(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([
$this->participant('a', 'Max', 'Meier', paymentOptions: ['payer_iban' => 'DE02120300000000202051']),
$this->participant('b', 'Lena', 'Kunze'),
]);
// Zweck sagt nichts, IBAN in der Schreibweise der Bank (mit Leerzeichen).
$match = $module->matchTransaction(
$this->transaction('Restzahlung', 60.0, payerIban: 'DE02 1203 0000 0000 2020 51'),
$candidates,
);
$this->assertNotNull($match);
$this->assertSame('a', $match->participant->identifier);
}
/** Regel 4: nur der Nachname im Zweck, aber der Betrag trifft den offenen Rest. */
public function test_matches_lastname_plus_exact_open_amount(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([
$this->participant('a', 'Max', 'Meier', amount: 120.0, amountPaid: 40.0),
$this->participant('b', 'Lena', 'Kunze', amount: 120.0),
]);
$match = $module->matchTransaction($this->transaction('Restbetrag Meier', 80.0), $candidates);
$this->assertNotNull($match);
$this->assertSame('a', $match->participant->identifier);
}
/** Regel 5: das Konto läuft auf den Namen -- trägt oft, bei Elternkonten aber nicht. */
public function test_matches_payer_name_only_as_uncertain(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([$this->participant('a', 'Max', 'Meier')]);
$match = $module->matchTransaction($this->transaction('Ueberweisung', 99.0, payerName: 'Max Meier'), $candidates);
$this->assertNotNull($match);
$this->assertSame(TransactionMatch::CONFIDENCE_UNCERTAIN, $match->confidence);
}
/**
* Zwei Namensgleiche: lieber kein Vorschlag als der falsche. In der Prüfansicht wäre die
* Verwechslung nicht zu erkennen und würde durchgewinkt.
*/
public function test_no_suggestion_when_two_candidates_fit(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([
$this->participant('a', 'Max', 'Meier'),
$this->participant('b', 'Max', 'Meier'),
]);
$this->assertNull($module->matchTransaction($this->transaction('Beitrag Max Meier'), $candidates));
}
public function test_no_suggestion_when_nothing_fits(): void
{
$module = new AccountTransferPaymentModule();
$candidates = $this->candidates([$this->participant('a', 'Max', 'Meier')]);
$this->assertNull($module->matchTransaction($this->transaction('Rechnung 4711', 60.0, payerName: 'ACME GmbH'), $candidates));
}
public function test_records_the_payer_account(): void
{
$module = new AccountTransferPaymentModule();
$participant = $this->participant('a', 'Max', 'Meier');
$module->recordTransaction(
$participant,
$this->transaction('Beitrag', 120.0, payerName: 'Max Meier', payerIban: 'DE02 1203 0000 0000 2020 51'),
);
$this->assertSame('DE02120300000000202051', $participant->payment_options['payer_iban']);
$this->assertSame('Max Meier', $participant->payment_options['payer_account_owner']);
$this->assertTrue($participant->refund_data);
}
/**
* Manche Banken lassen den Namen bei Folgezahlungen leer. Der bereits bekannte Kontoinhaber darf
* dadurch nicht verlorengehen -- ohne ihn ist die IBAN für die Erstattung wertlos.
*/
public function test_a_follow_up_without_payer_name_keeps_the_known_owner(): void
{
$module = new AccountTransferPaymentModule();
$participant = $this->participant('a', 'Max', 'Meier', paymentOptions: [
'payer_iban' => 'DE02120300000000202051',
'payer_account_owner' => 'Max Meier',
]);
$participant->refund_data = true;
$module->recordTransaction(
$participant,
$this->transaction('Restzahlung', 60.0, payerName: '', payerIban: 'DE02120300000000202051'),
);
$this->assertSame('Max Meier', $participant->payment_options['payer_account_owner']);
$this->assertTrue($participant->refund_data);
}
/**
* Eine IBAN mit Zahlendreher besteht die Prüfziffer nicht und wird nicht übernommen -- sonst
* ginge die Erstattung später an eine fremde Person.
*/
public function test_ignores_an_invalid_iban(): void
{
$module = new AccountTransferPaymentModule();
$participant = $this->participant('a', 'Max', 'Meier');
$module->recordTransaction(
$participant,
$this->transaction('Beitrag', 120.0, payerName: 'Max Meier', payerIban: 'DE02120300000000202015'),
);
$this->assertSame([], $participant->payment_options);
$this->assertNotTrue($participant->refund_data);
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
namespace Tests\Unit;
use App\Providers\BankStatementParseProvider;
use App\Support\BankStatementParseException;
use App\ValueObjects\BankStatementRuleset;
use PHPUnit\Framework\TestCase;
/**
* Der Parser -- zahlartneutral und ohne Framework. Getestet werden die Formatfallen, an denen ein
* Bank-Export scheitert: Zeichensatz, Trennzeichen, Zahlformat, fehlende Spalten.
*/
class BankStatementParseTest extends TestCase
{
private function ruleset(array $overrides = []): BankStatementRuleset
{
$base = [
'delimiter' => ';',
'enclosure' => '',
'charset' => 'Windows-1252',
'has_header' => true,
'date_format' => 'd.m.Y',
'decimal_separator' => ',',
'thousands_separator' => '.',
'columns' => [
'payment_date' => 'Buchungstag',
'purpose' => 'Verwendungszweck',
'amount' => 'Betrag',
'payer_name' => 'Name Zahlungsbeteiligter',
'payer_iban' => 'IBAN Zahlungsbeteiligter',
],
];
return new BankStatementRuleset(
delimiter: $overrides['delimiter'] ?? $base['delimiter'],
enclosure: $overrides['enclosure'] ?? $base['enclosure'],
charset: $overrides['charset'] ?? $base['charset'],
hasHeader: $overrides['has_header'] ?? $base['has_header'],
dateFormat: $overrides['date_format'] ?? $base['date_format'],
decimalSeparator: $overrides['decimal_separator'] ?? $base['decimal_separator'],
thousandsSeparator: $overrides['thousands_separator'] ?? $base['thousands_separator'],
columns: $overrides['columns'] ?? $base['columns'],
);
}
/** Das Format, das die GLS heute liefert: Semikolon, keine Anführungszeichen, CRLF. */
public function test_reads_the_semicolon_export_without_enclosures(): void
{
$csv = "Buchungstag;Valutadatum;Name Zahlungsbeteiligter;IBAN Zahlungsbeteiligter;Verwendungszweck;Betrag\r\n"
. "09.09.2026;09.09.2026;Max Meier;DE02120300000000202051;Sommerlager - Beitrag Max Meier;120,00\r\n";
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset());
$this->assertCount(1, $transactions);
$this->assertSame('2026-09-09', $transactions[0]->paymentDate->format('Y-m-d'));
$this->assertSame(120.0, $transactions[0]->amount->getAmount());
$this->assertSame('Max Meier', $transactions[0]->payerName);
$this->assertSame('DE02120300000000202051', $transactions[0]->payerIban);
$this->assertSame('Sommerlager - Beitrag Max Meier', $transactions[0]->purpose);
// Kopfzeile ist Zeile 1.
$this->assertSame(2, $transactions[0]->rowNumber);
}
public function test_reads_comma_separated_export_with_enclosures(): void
{
$csv = "\"Buchungstag\",\"Verwendungszweck\",\"Betrag\"\n"
. "\"09.09.2026\",\"Beitrag, erste Rate\",\"85,50\"\n";
$transactions = new BankStatementParseProvider()->parse(
$csv,
$this->ruleset([
'delimiter' => ',',
'enclosure' => '"',
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]),
);
$this->assertCount(1, $transactions);
// Das Komma im Zweck darf die Zeile nicht zerreißen.
$this->assertSame('Beitrag, erste Rate', $transactions[0]->purpose);
$this->assertSame(85.5, $transactions[0]->amount->getAmount());
}
public function test_converts_windows_1252_umlauts(): void
{
$csv = mb_convert_encoding(
"Buchungstag;Verwendungszweck;Betrag\r\n09.09.2026;Beitrag Jörg Müller;60,00\r\n",
'Windows-1252',
'UTF-8',
);
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]));
$this->assertSame('Beitrag Jörg Müller', $transactions[0]->purpose);
}
/**
* Der Schutz gegen Doppel-Kodierung: Liefert die Bank entgegen der Einstellung UTF-8, darf aus
* „Müller" kein „Müller" werden. Die Konvertierung würde technisch gelingen -- der Fehler fiele
* erst auf der Rechnung auf.
*/
public function test_does_not_double_encode_utf8_content(): void
{
$csv = "Buchungstag;Verwendungszweck;Betrag\r\n09.09.2026;Beitrag Jörg Müller;60,00\r\n";
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([
'charset' => 'Windows-1252',
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]));
$this->assertSame('Beitrag Jörg Müller', $transactions[0]->purpose);
}
public function test_parses_german_thousands_and_negative_amounts(): void
{
$csv = "Buchungstag;Verwendungszweck;Betrag\n"
. "09.09.2026;Grosse Zahlung;1.234,56\n"
. "10.09.2026;Rueckbuchung;-56,00\n";
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]));
// Der Parser filtert nicht -- ob eine Belastung zählt, entscheidet das Zahlungsmodul.
$this->assertCount(2, $transactions);
$this->assertSame(1234.56, $transactions[0]->amount->getAmount());
$this->assertSame(-56.0, $transactions[1]->amount->getAmount());
$this->assertTrue($transactions[0]->isCredit());
$this->assertFalse($transactions[1]->isCredit());
}
/** Summenzeilen und Fußnoten sind keine Umsätze und dürfen den Import nicht abbrechen. */
public function test_skips_rows_without_date_or_amount(): void
{
$csv = "Buchungstag;Verwendungszweck;Betrag\n"
. "09.09.2026;Beitrag;120,00\n"
. ";Summe;;\n"
. "10.09.2026;Ohne Betrag;\n";
$transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]));
$this->assertCount(1, $transactions);
}
/** Der Normalfall bei falsch eingestelltem Ruleset -- die Meldung muss die Spalte nennen. */
public function test_names_the_missing_column(): void
{
$csv = "Buchungstag;Verwendungszweck\n09.09.2026;Beitrag\n";
$this->expectException(BankStatementParseException::class);
$this->expectExceptionMessageMatches('/Betrag/');
new BankStatementParseProvider()->parse($csv, $this->ruleset([
'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'],
]));
}
public function test_rejects_a_ruleset_without_required_mapping(): void
{
$this->expectException(BankStatementParseException::class);
$this->expectExceptionMessageMatches('/amount/');
new BankStatementParseProvider()->parse(
"Buchungstag;Verwendungszweck\n",
$this->ruleset(['columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck']]),
);
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Tests\Unit;
use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
use App\Models\PaymentMethod;
use App\ValueObjects\BankStatementRuleset;
use Tests\TestCase;
/**
* Wie sich der app-weite Standard aus config/bankStatement.php und ein Tenant-Override zueinander
* verhalten -- und dass das Ruleset nicht in den Event-Snapshot rutscht.
*
* Braucht den Framework-Kontext (config()), aber keine Datenbank.
*/
class BankStatementRulesetTest extends TestCase
{
public function test_empty_configuration_falls_back_to_the_app_default(): void
{
$ruleset = new AccountTransferPaymentModule()->statementRuleset([]);
$this->assertSame(';', $ruleset->delimiter);
$this->assertSame('', $ruleset->enclosure);
$this->assertSame('Windows-1252', $ruleset->charset);
$this->assertSame('Buchungstag', $ruleset->column('payment_date'));
$this->assertSame([], $ruleset->missingRequiredColumns());
}
/**
* Der Override gilt ganz oder gar nicht: Wer die Spalten seiner Bank einträgt, bekommt nicht
* daneben noch die Spaltennamen der GLS untergeschoben und sucht den Fehler an der falschen Stelle.
*/
public function test_a_tenant_override_replaces_the_column_mapping_entirely(): void
{
$ruleset = new AccountTransferPaymentModule()->statementRuleset([
AccountTransferPaymentModule::OPTION_STATEMENT_RULESET => [
'delimiter' => ',',
'enclosure' => '"',
'columns' => [
'payment_date' => 'Datum',
'purpose' => 'Zweck',
'amount' => 'Summe',
],
],
]);
$this->assertSame(',', $ruleset->delimiter);
$this->assertSame('"', $ruleset->enclosure);
$this->assertSame('Datum', $ruleset->column('payment_date'));
// Nicht aus dem App-Standard ergänzt:
$this->assertNull($ruleset->column('payer_iban'));
}
/** Das Frontend schickt das Ruleset als JSON-String durch das generische Options-Formular. */
public function test_accepts_the_override_as_json_string(): void
{
$ruleset = new AccountTransferPaymentModule()->statementRuleset([
AccountTransferPaymentModule::OPTION_STATEMENT_RULESET => json_encode([
'delimiter' => "\t",
'columns' => ['payment_date' => 'Datum', 'purpose' => 'Zweck', 'amount' => 'Summe'],
]),
]);
$this->assertSame("\t", $ruleset->delimiter);
$this->assertSame('Datum', $ruleset->column('payment_date'));
}
public function test_reports_a_missing_required_column(): void
{
$ruleset = BankStatementRuleset::fromConfiguration([
'columns' => ['payment_date' => 'Datum', 'purpose' => 'Zweck'],
]);
$this->assertSame(['amount'], $ruleset->missingRequiredColumns());
}
/**
* Das Kontoauszug-Format beschreibt die Bank, nicht die Zusage an die Teilnehmenden. Im
* Event-Snapshot eingefroren ließe sich nach einem Bankwechsel für laufende Aktionen nichts mehr
* importieren -- IBAN und Kontoinhaber frieren dagegen weiterhin pro Aktion ein.
*/
public function test_the_ruleset_is_stripped_from_the_event_snapshot(): void
{
$configuration = [
'account_owner' => 'Kasse',
'iban' => 'DE02120300000000202051',
AccountTransferPaymentModule::OPTION_STATEMENT_RULESET => ['delimiter' => ','],
];
$snapshot = PaymentMethod::stripTenantScopedOptions(
PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
$configuration,
);
$this->assertArrayNotHasKey(AccountTransferPaymentModule::OPTION_STATEMENT_RULESET, $snapshot);
$this->assertSame('Kasse', $snapshot['account_owner']);
$this->assertSame('DE02120300000000202051', $snapshot['iban']);
}
public function test_the_ruleset_option_is_the_only_tenant_scoped_one(): void
{
$this->assertSame(
[AccountTransferPaymentModule::OPTION_STATEMENT_RULESET],
new AccountTransferPaymentModule()->tenantScopedOptionKeys(),
);
}
}
+21 -2
View File
@@ -52,14 +52,33 @@ class EventPaymentModuleRegistryTest extends TestCase
public function test_participant_options_per_module(): void public function test_participant_options_per_module(): void
{ {
// Überweisung: keine payer-seitigen Eingaben. // Überweisung: keine payer-seitigen Eingaben im Anmeldeformular.
$this->assertSame([], (new AccountTransferPaymentModule())->getParticipantOptions()); $this->assertSame([], (new AccountTransferPaymentModule())->participantInputOptions());
// Sonstiges (Barzahlung vor Ort): ebenfalls keine payer-seitigen Eingaben -- die Zahlungsart // Sonstiges (Barzahlung vor Ort): ebenfalls keine payer-seitigen Eingaben -- die Zahlungsart
// beschreibt sich allein über den vom Veranstalter gepflegten Freitext. // beschreibt sich allein über den vom Veranstalter gepflegten Freitext.
$this->assertSame([], (new UndefinedPaymentModule())->getParticipantOptions()); $this->assertSame([], (new UndefinedPaymentModule())->getParticipantOptions());
} }
public function test_system_participant_options_stay_in_the_schema(): void
{
$module = new AccountTransferPaymentModule();
// Die Zahler-Konto-Felder trägt der Zahlungsimport nach, gefragt wird beim Anmelden nicht
// danach. Im Schema müssen sie trotzdem stehen -- sonst wirft sanitizeParticipantOptions()
// sie beim nächsten Durchlauf als unbekannte Schlüssel weg.
$names = array_column($module->getParticipantOptions(), 'name');
$this->assertSame(['payer_account_owner', 'payer_iban'], $names);
$this->assertSame(
['payer_iban' => 'DE02120300000000202051'],
$module->sanitizeParticipantOptions(['payer_iban' => 'DE02120300000000202051', 'evil' => 'x'])
);
// Kein Pflichtfeld -- eine Anmeldung ohne Zahlungseingang bleibt vollständig.
$this->assertTrue($module->participantOptionsComplete([]));
}
public function test_participant_option_helpers(): void public function test_participant_option_helpers(): void
{ {
// Anonymes Modul mit einer Pflicht-Teilnehmereingabe. // Anonymes Modul mit einer Pflicht-Teilnehmereingabe.