Automatischer Zahlungsparser

This commit is contained in:
2026-09-10 09:50:14 +02:00
parent 025035190d
commit 6301c342e4
24 changed files with 1692 additions and 83 deletions
+375
View File
@@ -0,0 +1,375 @@
<?php
namespace Tests\Feature;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\EfzStatus;
use App\Enumerations\InvoiceStatus;
use App\Enumerations\RefundReason;
use App\Enumerations\UserRole;
use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
use App\Models\CostUnit;
use App\Models\DocumentTemplate;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\ParticipantRefund;
use App\Models\PaymentMethod;
use App\Models\Tenant;
use App\Models\User;
use App\RelationModels\EventParticipationFee;
use App\ValueObjects\Amount;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* Erstattung auf das Konto, das der Zahlungsimport bereits kennt.
*
* Der Teili tippt dann keine IBAN mehr ab -- er entscheidet nur noch, ob er den Betrag haben oder
* spenden möchte. Wo nichts bekannt ist (Altbestand, Barzahlung), bleibt alles wie zuvor.
*/
class RefundKnownAccountTest extends TestCase
{
use RefreshDatabase;
private const string KNOWN_IBAN = 'DE02120300000000202051';
private const string KNOWN_OWNER = 'Mika Muster';
private Tenant $tenant;
private int $sequence = 0;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::create([
'slug' => 'wm', 'name' => 'Wilde Möhre', 'address_1' => 'Musterweg 1',
'email' => 't@example.com', 'email_finance' => 'finance@example.com',
'url' => parse_url(config('app.url'), PHP_URL_HOST),
'account_name' => 'Test e.V.', 'account_iban' => 'DE00', 'account_bic' => 'XY',
'city' => 'Stadt', 'postcode' => '00000', 'invoice_prefix' => 'WM',
'is_active_local_group' => true, 'has_active_instance' => true,
]);
app()->instance('tenant', $this->tenant);
DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']);
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']);
DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) {
UserRole::create(['slug' => $role, 'name' => $role]);
}
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_LAYOUT,
'content' => '<div>{block:body}</div>',
'sort_order' => 10,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_BODY,
'content' => '{details_table}<p>{declaration_text}</p>',
'sort_order' => 20,
]);
$this->actingAs($this->makeUser('Aktions', 'Leitung', UserRole::USER_ROLE_ADMIN));
Storage::fake('local');
Mail::fake();
}
private function makeUser(string $firstname, string $lastname, string $role): User
{
return User::create([
'username' => strtolower($lastname) . '-' . uniqid() . '@example.com',
'email' => strtolower($lastname) . '-' . uniqid() . '@example.com',
'firstname' => $firstname, 'lastname' => $lastname, 'password' => bcrypt('secret'),
'local_group' => $this->tenant->slug, 'user_role_main' => $role,
'user_role_local_group' => UserRole::USER_ROLE_USER, 'active' => true,
]);
}
private function makeEvent(): Event
{
$fee = EventParticipationFee::create([
'tenant' => $this->tenant->slug, 'type' => 'participant', 'name' => 'Sippe',
'description' => null, 'amount_standard' => 60.0,
'amount_reduced' => null, 'amount_solidarity' => null,
]);
$costUnit = CostUnit::create([
'tenant' => $this->tenant->slug, 'name' => 'Sommerlager',
'type' => CostUnitType::COST_UNIT_TYPE_EVENT, 'distance_allowance' => 0.25,
'mail_on_new' => false, 'allow_new' => true, 'archived' => false,
]);
return Event::create([
'cost_unit_id' => $costUnit->id, 'tenant' => $this->tenant->slug, 'name' => 'Sommerlager',
'identifier' => 'evt-' . uniqid(), 'location' => 'Ort', 'postal_code' => '00000',
'email' => 'e@example.com', 'start_date' => '2026-07-16', 'end_date' => '2026-07-20',
'early_bird_end' => '2026-06-20', 'registration_final_end' => '2026-07-01',
'early_bird_end_amount_increase' => 0, 'account_owner' => 'Owner', 'account_iban' => 'DE00',
'participation_fee_type' => 'fixed', 'participation_fee_1' => $fee->id,
'pay_per_day' => true, 'pay_direct' => false, 'tax_liable' => false, 'vat_rate' => 0,
'vat_pricing_mode' => 'inclusive', 'invoice_key' => 'WM-V-20260701',
]);
}
/** Standardfall: abgemeldet, voll gezahlt, Konto aus dem Zahlungseingang bekannt. */
private function makeParticipant(array $attributes = []): EventParticipant
{
$this->sequence++;
return $this->makeEvent()->participants()->create(array_merge([
'tenant' => $this->tenant->slug, 'identifier' => 'p-' . uniqid(),
'invoice_sequence' => $this->sequence,
'user_id' => $this->makeUser('Mika', 'Muster', UserRole::USER_ROLE_USER)->id,
'firstname' => 'Mika', 'lastname' => 'Muster', 'participation_type' => 'participant',
'fee_type' => 'standard', 'sibling_reduction' => false,
'local_group' => $this->tenant->slug, 'birthday' => '2000-01-01',
'address_1' => 'Beispielstraße 3', 'postcode' => '11111', 'city' => 'Beispielstadt',
'email_1' => 'mika@example.com', 'phone_1' => '0170 0000000',
'arrival_date' => '2026-07-16', 'departure_date' => '2026-07-20',
'arrival_eating' => 1, 'departure_eating' => 1,
'amount' => 300.0, 'amount_paid' => 300.0, 'unregistered_at' => '2026-06-12',
'payment_purpose' => 'Sommerlager',
'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
// Was der Zahlungsimport hinterlassen hat.
'payment_options' => [
AccountTransferPaymentModule::OPTION_PAYER_IBAN => self::KNOWN_IBAN,
AccountTransferPaymentModule::OPTION_PAYER_ACCOUNT_OWNER => self::KNOWN_OWNER,
],
'refund_data' => true,
'last_payment_date' => '2026-05-02',
], $attributes));
}
private function release(EventParticipant $participant): ParticipantRefund
{
$response = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount(300.0, 'Euro'),
reason: RefundReason::SICKNESS,
))->execute();
$this->assertTrue($response->success, $response->message);
return $response->refund;
}
/*
|--------------------------------------------------------------------------
| Freigabe
|--------------------------------------------------------------------------
*/
public function test_the_known_account_is_stored_but_the_refund_stays_pending(): void
{
$refund = $this->release($this->makeParticipant());
// Offen, weil der Teili noch entscheiden soll: auszahlen oder spenden.
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->status);
$this->assertSame(self::KNOWN_IBAN, $refund->account_iban);
$this->assertSame(self::KNOWN_OWNER, $refund->account_owner);
}
public function test_a_participant_without_payment_data_gets_no_account(): void
{
$refund = $this->release($this->makeParticipant(['payment_options' => [], 'refund_data' => false]));
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->status);
$this->assertNull($refund->account_iban);
$this->assertNull($refund->account_owner);
}
/** Barzahlung kennt kein Konto -- auch dann, wenn zufällig etwas in den Optionen steht. */
public function test_cash_payment_gets_no_account(): void
{
$refund = $this->release($this->makeParticipant([
'payment_method' => PaymentMethod::PAYMENT_NOT_DEFINED,
]));
$this->assertNull($refund->account_iban);
}
/*
|--------------------------------------------------------------------------
| Was nach draußen geht
|--------------------------------------------------------------------------
*/
public function test_the_release_mail_shows_the_iban_only_masked(): void
{
$participant = $this->makeParticipant();
$this->release($participant);
Mail::assertSent(RefundReleasedMail::class, function (RefundReleasedMail $mail) {
$rendered = $mail->render();
$this->assertStringContainsString('DE02 •••• •••• •••• ••20 51', $rendered);
// Die vollständige IBAN liegt sonst in einem Postfach.
$this->assertStringNotContainsString(self::KNOWN_IBAN, $rendered);
$this->assertStringNotContainsString('DE02 1203 0000 0000 2020 51', $rendered);
// Nach der Bankverbindung wird nicht mehr gefragt, nach der Spende schon.
$this->assertStringNotContainsString('brauchen wir noch deine Bankverbindung', $rendered);
$this->assertStringContainsString('spenden', $rendered);
return true;
});
}
public function test_the_public_page_never_exposes_the_full_iban(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant);
$this->get('/rueckerstattung/' . $refund->token)
->assertOk()
->assertInertia(fn ($page) => $page
->where('state', 'open')
->where('knownAccount.owner', self::KNOWN_OWNER)
->where('knownAccount.ibanMasked', 'DE02 •••• •••• •••• ••20 51'));
// Und zwar nirgends in der Antwort -- auch nicht in einem Feld, das niemand liest.
$this->assertStringNotContainsString(
self::KNOWN_IBAN,
$this->get('/rueckerstattung/' . $refund->token)->getContent(),
);
}
public function test_the_public_page_asks_for_the_account_when_nothing_is_known(): void
{
$refund = $this->release($this->makeParticipant(['payment_options' => [], 'refund_data' => false]));
// Kein Konto zum Anzeigen -- das Formular bleibt, wie es war.
$this->get('/rueckerstattung/' . $refund->token)
->assertOk()
->assertInertia(fn ($page) => $page->where('state', 'open')->where('knownAccount', null));
}
/*
|--------------------------------------------------------------------------
| Bestätigung
|--------------------------------------------------------------------------
*/
public function test_the_participant_only_confirms_and_the_account_stands(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant);
// Ohne Kontofelder -- genau das schickt die Seite bei bekanntem Konto.
$response = new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: '',
accountIban: '',
declarationAccepted: true,
accountDeclarationAccepted: true,
))->execute();
$this->assertTrue($response->success, $response->message);
$refund->refresh();
$this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->status);
$this->assertSame(self::KNOWN_IBAN, $refund->account_iban);
$this->assertSame(self::KNOWN_OWNER, $refund->account_owner);
}
/**
* Der eigentliche Gewinn: Wer den Token hat, kann die Auszahlung nicht auf ein fremdes Konto
* umbiegen. Das Konto steht seit der Freigabe fest.
*/
public function test_a_foreign_iban_in_the_request_is_ignored(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant);
$response = $this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [
'donation' => false,
'accountOwner' => 'Fremde Person',
'accountIban' => 'DE89370400440532013000',
'declarationAccepted' => true,
'accountDeclarationAccepted' => true,
]);
$response->assertOk();
$response->assertJsonPath('status', 'success');
$refund->refresh();
$this->assertSame(self::KNOWN_IBAN, $refund->account_iban);
$this->assertSame(self::KNOWN_OWNER, $refund->account_owner);
}
/** Die Erklärungen bleiben Pflicht -- der Beleg zitiert sie. */
public function test_the_declarations_are_still_required(): void
{
$refund = $this->release($this->makeParticipant());
$response = new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
declarationAccepted: false,
accountDeclarationAccepted: false,
))->execute();
$this->assertFalse($response->success);
$this->assertArrayHasKey('declaration', $response->errorTypes);
$this->assertArrayHasKey('accountDeclaration', $response->errorTypes);
// Und keine Meckerei über Felder, die gar nicht mehr gezeigt werden.
$this->assertArrayNotHasKey('accountIban', $response->errorTypes);
$this->assertArrayNotHasKey('accountOwner', $response->errorTypes);
}
/**
* Wer spendet, bekommt nichts überwiesen -- die Kontofelder müssen leer werden, sonst zöge der
* SEPA-Export eine Auszahlung, die niemand wollte.
*/
public function test_a_donation_clears_the_known_account(): void
{
$refund = $this->release($this->makeParticipant());
$response = new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
donation: true,
declarationAccepted: true,
))->execute();
$this->assertTrue($response->success, $response->message);
$refund->refresh();
$this->assertNull($refund->account_iban);
$this->assertNull($refund->account_owner);
$this->assertTrue($refund->isDonation());
}
/** Ohne bekanntes Konto bleibt die Eingabe Pflicht wie eh und je. */
public function test_without_a_known_account_the_fields_are_still_required(): void
{
$refund = $this->release($this->makeParticipant(['payment_options' => [], 'refund_data' => false]));
$response = new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: '',
accountIban: '',
declarationAccepted: true,
accountDeclarationAccepted: true,
))->execute();
$this->assertFalse($response->success);
$this->assertArrayHasKey('accountOwner', $response->errorTypes);
$this->assertArrayHasKey('accountIban', $response->errorTypes);
}
}