Automatischer Zahlungsparser
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
|
||||
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
|
||||
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
|
||||
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentRequest;
|
||||
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\Mail\ParticipantRefundMails\RefundReleasedMail;
|
||||
use App\Models\CostUnit;
|
||||
use App\Models\DocumentTemplate;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventParticipant;
|
||||
use App\Models\PageText;
|
||||
use App\Models\ParticipantRefund;
|
||||
use App\Models\PaymentMethod;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Providers\DocumentTemplateRenderProvider;
|
||||
use App\RelationModels\EventParticipationFee;
|
||||
use App\ValueObjects\Amount;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use ReflectionMethod;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Erstattung an jemanden, der bar gezahlt hat.
|
||||
*
|
||||
* Es gibt kein Ursprungskonto -- die Frage danach wäre sinnlos und die Erklärung, es sei dasselbe
|
||||
* Konto, unwahr. Erstattet wird trotzdem per Überweisung: auf ein Konto, das der Person gehört.
|
||||
*/
|
||||
class RefundCashPayerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const string OWN_IBAN = 'DE02120300000000202051';
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
// Beide Kontoerklärungen legen die Migrationen bereits an. Hier bekommen sie einen
|
||||
// unverwechselbaren Wortlaut, damit die Tests sehen, welche der beiden gezogen wurde -- und
|
||||
// zwar unabhängig davon, wie die ausgelieferten Sätze gerade formuliert sind.
|
||||
PageText::where('name', CreateRefundDocumentCommand::ACCOUNT_DECLARATION_TEXT)
|
||||
->update(['content' => 'Erklärung Ursprungskonto.']);
|
||||
PageText::where('name', CreateRefundDocumentCommand::OWN_ACCOUNT_DECLARATION_TEXT)
|
||||
->update(['content' => 'Erklärung eigenes Konto.']);
|
||||
|
||||
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',
|
||||
]);
|
||||
}
|
||||
|
||||
private function makeParticipant(string $paymentMethod = PaymentMethod::PAYMENT_NOT_DEFINED): EventParticipant
|
||||
{
|
||||
$this->sequence++;
|
||||
|
||||
return $this->makeEvent()->participants()->create([
|
||||
'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,
|
||||
'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
|
||||
]);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Die Erstattungsseite
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_the_page_does_not_ask_for_an_origin_account(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant());
|
||||
|
||||
$this->get('/rueckerstattung/' . $refund->token)
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->where('state', 'open')
|
||||
->where('accountSource', 'none')
|
||||
->where('knownAccount', null)
|
||||
// Die Erklärung, die zur Barzahlung passt -- nicht die über das Ursprungskonto.
|
||||
->where('accountDeclarationText', CreateRefundDocumentCommand::OWN_ACCOUNT_DECLARATION_TEXT));
|
||||
}
|
||||
|
||||
/** Gegenprobe: Überweiser ohne erfassten Zahlungseingang bekommen weiterhin die Herkunftsfrage. */
|
||||
public function test_a_transfer_payer_still_gets_the_origin_question(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION));
|
||||
|
||||
$this->get('/rueckerstattung/' . $refund->token)
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->where('accountSource', 'origin')
|
||||
->where('accountDeclarationText', CreateRefundDocumentCommand::ACCOUNT_DECLARATION_TEXT));
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Die Freigabe-Mail
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_the_release_mail_asks_for_an_own_account(): void
|
||||
{
|
||||
$this->release($this->makeParticipant());
|
||||
|
||||
Mail::assertSent(RefundReleasedMail::class, function (RefundReleasedMail $mail) {
|
||||
$rendered = $mail->render();
|
||||
|
||||
// Der Hinweis auf das Ursprungskonto wäre hier falsch -- es gab keines.
|
||||
$this->assertStringNotContainsString('von dem der Teilnahmebeitrag', $rendered);
|
||||
$this->assertStringContainsString('auf deinen Namen läuft', $rendered);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_the_release_mail_keeps_the_origin_hint_for_transfer_payers(): void
|
||||
{
|
||||
$this->release($this->makeParticipant(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION));
|
||||
|
||||
Mail::assertSent(RefundReleasedMail::class, function (RefundReleasedMail $mail) {
|
||||
$this->assertStringContainsString('von dem der Teilnahmebeitrag', $mail->render());
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Bestätigung und Beleg
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_the_cash_payer_can_name_an_account_and_the_refund_completes(): void
|
||||
{
|
||||
$participant = $this->makeParticipant();
|
||||
$refund = $this->release($participant);
|
||||
|
||||
$response = $this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [
|
||||
'donation' => false,
|
||||
'accountOwner' => 'Mika Muster',
|
||||
'accountIban' => self::OWN_IBAN,
|
||||
'declarationAccepted' => true,
|
||||
'accountDeclarationAccepted' => true,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('status', 'success');
|
||||
|
||||
$refund->refresh();
|
||||
$this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->status);
|
||||
$this->assertSame(self::OWN_IBAN, $refund->account_iban);
|
||||
$this->assertSame('Mika Muster', $refund->account_owner);
|
||||
// Die Abrechnung entsteht wie bei jeder anderen Erstattung.
|
||||
$this->assertNotNull($refund->invoice_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Beleg darf dem Teili nicht zuschreiben, er habe bestätigt, es sei das Konto der
|
||||
* Ursprungszahlung -- die Frage wurde ihm nie gestellt.
|
||||
*/
|
||||
public function test_the_receipt_carries_the_own_account_declaration(): void
|
||||
{
|
||||
$participant = $this->makeParticipant();
|
||||
$refund = $this->release($participant);
|
||||
|
||||
new AcceptRefundCommand(new AcceptRefundRequest(
|
||||
refund: $refund,
|
||||
accountOwner: 'Mika Muster',
|
||||
accountIban: self::OWN_IBAN,
|
||||
declarationAccepted: true,
|
||||
accountDeclarationAccepted: true,
|
||||
))->execute();
|
||||
|
||||
$html = $this->receiptHtml($refund->fresh());
|
||||
|
||||
$this->assertStringContainsString('Erklärung eigenes Konto.', $html);
|
||||
$this->assertStringNotContainsString('Erklärung Ursprungskonto.', $html);
|
||||
}
|
||||
|
||||
public function test_the_receipt_of_a_transfer_payer_is_unchanged(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION));
|
||||
|
||||
new AcceptRefundCommand(new AcceptRefundRequest(
|
||||
refund: $refund,
|
||||
accountOwner: 'Mika Muster',
|
||||
accountIban: self::OWN_IBAN,
|
||||
declarationAccepted: true,
|
||||
accountDeclarationAccepted: true,
|
||||
))->execute();
|
||||
|
||||
$html = $this->receiptHtml($refund->fresh());
|
||||
|
||||
$this->assertStringContainsString('Erklärung Ursprungskonto.', $html);
|
||||
$this->assertStringNotContainsString('Erklärung eigenes Konto.', $html);
|
||||
}
|
||||
|
||||
/** Die Spende funktioniert für Barzahler unverändert -- dort gibt es ohnehin kein Konto. */
|
||||
public function test_a_cash_payer_can_donate(): 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->assertTrue($refund->isDonation());
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Beleg als HTML, vor dem PDF-Satz.
|
||||
*
|
||||
* Wie in {@see RefundDocumentTest::html()}: `execute()` liefert nur das fertige PDF, und in dessen
|
||||
* komprimierten Streams nach Text zu suchen wäre unzuverlässig. Stattdessen dieselben Tokens durch
|
||||
* denselben Render-Provider, den der Command auch benutzt.
|
||||
*/
|
||||
private function receiptHtml(ParticipantRefund $refund): string
|
||||
{
|
||||
$command = new CreateRefundDocumentCommand(
|
||||
new CreateRefundDocumentRequest($refund, donation: false)
|
||||
);
|
||||
$number = new ReflectionMethod($command, 'documentNumber')->invoke($command);
|
||||
$tokens = new ReflectionMethod($command, 'buildTokens')->invoke($command, $number);
|
||||
|
||||
return new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_REFUND)->render($tokens);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user