Creating Participation refunds
This commit is contained in:
@@ -0,0 +1,797 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
|
||||
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
|
||||
use App\Domains\ParticipantRefund\Actions\CancelRefund\CancelRefundCommand;
|
||||
use App\Domains\ParticipantRefund\Actions\CancelRefund\CancelRefundRequest;
|
||||
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
|
||||
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand;
|
||||
use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest;
|
||||
use App\Enumerations\EfzStatus;
|
||||
use App\Enumerations\RefundReason;
|
||||
use App\Enumerations\UserRole;
|
||||
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
|
||||
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
|
||||
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\Repositories\ParticipantRefundRepository;
|
||||
use App\ValueObjects\Amount;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Der Erstattungsvorgang: Freigabe durch die Aktionsleitung, Bestätigung durch den Teili, Abbruch.
|
||||
*/
|
||||
class ParticipantRefundTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Tenant $tenant;
|
||||
|
||||
/** Läuft mit, damit mehrere Teilis im selben Event nicht auf derselben Rechnungsnummer landen. */
|
||||
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',
|
||||
// Muss dem Host der Testanfragen entsprechen, sonst weist IdentifyTenant sie mit 404 ab.
|
||||
'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']);
|
||||
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]);
|
||||
}
|
||||
|
||||
$this->seedTemplate();
|
||||
|
||||
Mail::fake();
|
||||
}
|
||||
|
||||
/** Minimale Vorlage: alles, was der Beleg braucht, in wenigen Blöcken. */
|
||||
private function seedTemplate(): void
|
||||
{
|
||||
DocumentTemplate::create([
|
||||
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
|
||||
'block' => DocumentTemplate::BLOCK_LAYOUT,
|
||||
'content' => '<div>{block:subject}{block:body}</div>',
|
||||
'sort_order' => 10,
|
||||
]);
|
||||
|
||||
DocumentTemplate::create([
|
||||
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
|
||||
'block' => DocumentTemplate::BLOCK_STYLE,
|
||||
'content' => 'body { font-size: 10pt; }',
|
||||
'sort_order' => 20,
|
||||
]);
|
||||
|
||||
DocumentTemplate::create([
|
||||
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
|
||||
'block' => 'subject',
|
||||
'content' => '<h1>Rückerstattung {document_number}</h1><p>{document_date} / {event_name}</p>',
|
||||
'sort_order' => 30,
|
||||
]);
|
||||
|
||||
DocumentTemplate::create([
|
||||
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
|
||||
'block' => DocumentTemplate::BLOCK_BODY,
|
||||
'content' => '<div>{recipient_name}</div>{details_table}',
|
||||
'sort_order' => 40,
|
||||
]);
|
||||
}
|
||||
|
||||
private function makeEvent(array $attributes = []): Event
|
||||
{
|
||||
$fee = EventParticipationFee::create([
|
||||
'tenant' => $this->tenant->slug,
|
||||
'type' => 'participant',
|
||||
'name' => 'Sippe',
|
||||
'description' => null,
|
||||
'amount_standard' => 60.0,
|
||||
'amount_reduced' => null,
|
||||
'amount_solidarity' => null,
|
||||
]);
|
||||
|
||||
return Event::create(array_merge([
|
||||
'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',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/** Standardfall dieser Tests: abgemeldet und vollständig bezahlt. */
|
||||
private function makeParticipant(Event $event, array $attributes = []): EventParticipant
|
||||
{
|
||||
$this->sequence++;
|
||||
|
||||
return $event->participants()->create(array_merge([
|
||||
'tenant' => $this->tenant->slug,
|
||||
'identifier' => 'p-' . uniqid(),
|
||||
'invoice_sequence' => $this->sequence,
|
||||
'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,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
private function release(
|
||||
EventParticipant $participant,
|
||||
float $amount = 300.0,
|
||||
string $reason = RefundReason::SICKNESS,
|
||||
?string $note = null,
|
||||
) {
|
||||
return new ReleaseRefundCommand(new ReleaseRefundRequest(
|
||||
participant: $participant,
|
||||
amount: new Amount($amount, 'Euro'),
|
||||
reason: $reason,
|
||||
reasonNote: $note,
|
||||
))->execute();
|
||||
}
|
||||
|
||||
private function accept(
|
||||
?ParticipantRefund $refund,
|
||||
string $owner = 'Mika Muster',
|
||||
string $iban = 'DE02120300000000202051',
|
||||
bool $declarationAccepted = true,
|
||||
) {
|
||||
return new AcceptRefundCommand(new AcceptRefundRequest(
|
||||
refund: $refund,
|
||||
accountOwner: $owner,
|
||||
accountIban: $iban,
|
||||
declarationAccepted: $declarationAccepted,
|
||||
))->execute();
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Freigabe
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_release_creates_a_pending_refund_with_a_token(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent());
|
||||
|
||||
$response = $this->release($participant);
|
||||
|
||||
$this->assertTrue($response->success);
|
||||
$this->assertSame(ParticipantRefund::STATUS_PENDING, $response->refund->status);
|
||||
$this->assertSame(32, strlen($response->refund->token));
|
||||
$this->assertEqualsWithDelta(300.0, $response->refund->amount->getAmount(), 0.001);
|
||||
$this->assertNotNull($response->refund->released_at);
|
||||
}
|
||||
|
||||
public function test_release_notifies_participant_and_contact_person(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent(), ['email_2' => 'eltern@example.com']);
|
||||
|
||||
$this->release($participant);
|
||||
|
||||
Mail::assertSent(RefundReleasedMail::class, 2);
|
||||
Mail::assertSent(RefundReleasedMail::class, fn ($mail) => $mail->hasTo('mika@example.com'));
|
||||
Mail::assertSent(RefundReleasedMail::class, fn ($mail) => $mail->hasTo('eltern@example.com'));
|
||||
}
|
||||
|
||||
public function test_release_sends_only_one_mail_without_contact_person(): void
|
||||
{
|
||||
$this->release($this->makeParticipant($this->makeEvent()));
|
||||
|
||||
Mail::assertSent(RefundReleasedMail::class, 1);
|
||||
}
|
||||
|
||||
public function test_release_is_rejected_for_a_registered_participant(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent(), ['unregistered_at' => null]);
|
||||
|
||||
$response = $this->release($participant);
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertStringContainsString('abgemeldete', $response->message);
|
||||
$this->assertSame(0, ParticipantRefund::count());
|
||||
}
|
||||
|
||||
public function test_release_is_rejected_above_the_paid_amount(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent(), ['amount_paid' => 100.0]);
|
||||
|
||||
$response = $this->release($participant, 150.0);
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertStringContainsString('nicht übersteigen', $response->message);
|
||||
}
|
||||
|
||||
public function test_release_accepts_a_partial_amount(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent());
|
||||
|
||||
$response = $this->release($participant, 220.0);
|
||||
|
||||
$this->assertTrue($response->success);
|
||||
$this->assertEqualsWithDelta(220.0, $response->refund->amount->getAmount(), 0.001);
|
||||
}
|
||||
|
||||
public function test_release_is_rejected_without_an_amount(): void
|
||||
{
|
||||
$response = $this->release($this->makeParticipant($this->makeEvent()), 0.0);
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertStringContainsString('größer als 0', $response->message);
|
||||
}
|
||||
|
||||
public function test_release_is_rejected_for_an_unknown_reason(): void
|
||||
{
|
||||
$response = $this->release($this->makeParticipant($this->makeEvent()), 300.0, 'erfunden');
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertStringContainsString('Erstattungsgrund', $response->message);
|
||||
}
|
||||
|
||||
public function test_free_text_reason_requires_a_note(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent());
|
||||
|
||||
$response = $this->release($participant, 300.0, RefundReason::OTHER, ' ');
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertStringContainsString('Erläuterung', $response->message);
|
||||
}
|
||||
|
||||
public function test_note_is_only_stored_for_reasons_that_require_it(): void
|
||||
{
|
||||
$event = $this->makeEvent();
|
||||
|
||||
$withNote = $this->release($this->makeParticipant($event), 300.0, RefundReason::OTHER, 'Umzug');
|
||||
$this->assertSame('Umzug', $withNote->refund->reason_note);
|
||||
|
||||
$ignored = $this->release($this->makeParticipant($event), 300.0, RefundReason::SICKNESS, 'wird verworfen');
|
||||
$this->assertNull($ignored->refund->reason_note);
|
||||
}
|
||||
|
||||
public function test_a_second_release_is_rejected_while_one_is_open(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent());
|
||||
$this->release($participant);
|
||||
|
||||
$response = $this->release($participant);
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertStringContainsString('läuft bereits', $response->message);
|
||||
$this->assertSame(1, ParticipantRefund::count());
|
||||
}
|
||||
|
||||
public function test_a_new_release_is_possible_after_a_cancellation(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent());
|
||||
$first = $this->release($participant);
|
||||
|
||||
new CancelRefundCommand(new CancelRefundRequest($first->refund))->execute();
|
||||
|
||||
$this->assertTrue($this->release($participant)->success);
|
||||
$this->assertSame(2, ParticipantRefund::count());
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Bestätigung durch den Teili
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_accept_stores_the_normalized_bank_details(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
|
||||
$response = $this->accept($refund, 'Mika Muster', 'de02 1203 0000 0000 2020 51');
|
||||
|
||||
$this->assertTrue($response->success);
|
||||
|
||||
$refund->refresh();
|
||||
$this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->status);
|
||||
$this->assertSame('Mika Muster', $refund->account_owner);
|
||||
$this->assertSame('DE02120300000000202051', $refund->account_iban);
|
||||
$this->assertNotNull($refund->accepted_at);
|
||||
}
|
||||
|
||||
public function test_accept_sends_the_confirmation_with_the_document_attached(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent(), ['email_2' => 'eltern@example.com']);
|
||||
$refund = $this->release($participant)->refund;
|
||||
|
||||
$this->accept($refund);
|
||||
|
||||
Mail::assertSent(RefundAcceptedMail::class, 2);
|
||||
$expected = sprintf(
|
||||
'Rueckerstattung-WM-V-20260701-%s-R.pdf',
|
||||
str_pad((string) $participant->invoice_sequence, 4, '0', STR_PAD_LEFT)
|
||||
);
|
||||
|
||||
Mail::assertSent(RefundAcceptedMail::class, function (RefundAcceptedMail $mail) use ($expected): bool {
|
||||
$attachments = $mail->attachments();
|
||||
|
||||
return count($attachments) === 1 && $attachments[0]->as === $expected;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_accept_rejects_an_invalid_iban(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
|
||||
// Gültige Struktur und Länge, aber falsche Prüfziffer -- ein klassischer Zahlendreher.
|
||||
$response = $this->accept($refund, 'Mika Muster', 'DE02120300000000202015');
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertArrayHasKey('accountIban', $response->errorTypes);
|
||||
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Die Erklärung
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_accept_is_rejected_without_the_declaration(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
|
||||
$response = $this->accept($refund, declarationAccepted: false);
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertArrayHasKey('declaration', $response->errorTypes);
|
||||
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
|
||||
$this->assertNull($refund->fresh()->account_iban);
|
||||
}
|
||||
|
||||
public function test_the_declaration_cannot_be_skipped_over_http(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
|
||||
// Ohne die Prüfung im Command ließe sich die Erklärung mit einem direkten Aufruf übergehen --
|
||||
// und der Beleg schriebe dem Teili eine Zusicherung zu, die er nie abgegeben hat.
|
||||
$this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [
|
||||
'accountOwner' => 'Mika Muster',
|
||||
'accountIban' => 'DE02 1203 0000 0000 2020 51',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'error')
|
||||
->assertJsonStructure(['error_types' => ['declaration']]);
|
||||
|
||||
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_the_public_page_serves_the_declaration_text_name(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
|
||||
$this->get('/rueckerstattung/' . $refund->token)
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page->where('state', 'open'));
|
||||
|
||||
// Den Wortlaut holt die Seite über dieselbe Quelle, aus der ihn auch der Beleg nimmt.
|
||||
$this->get('/api/v1/core/retrieve-text-resource/' . CreateRefundDocumentCommand::DECLARATION_TEXT)
|
||||
->assertOk()
|
||||
->assertSee('Ich versichere', false);
|
||||
}
|
||||
|
||||
public function test_accept_requires_an_account_owner(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
|
||||
$response = $this->accept($refund, ' ');
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertArrayHasKey('accountOwner', $response->errorTypes);
|
||||
}
|
||||
|
||||
public function test_accept_on_an_unknown_token_reports_no_open_refund(): void
|
||||
{
|
||||
$response = $this->accept(null);
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertSame(AcceptRefundCommand::NO_OPEN_REFUND, $response->message);
|
||||
}
|
||||
|
||||
public function test_accept_on_a_cancelled_refund_reports_no_open_refund(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
new CancelRefundCommand(new CancelRefundRequest($refund))->execute();
|
||||
|
||||
$response = $this->accept($refund->fresh());
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertSame(AcceptRefundCommand::NO_OPEN_REFUND, $response->message);
|
||||
}
|
||||
|
||||
public function test_accept_twice_does_not_change_the_stored_details(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
$this->accept($refund, 'Mika Muster', 'DE02120300000000202051');
|
||||
|
||||
$response = $this->accept($refund->fresh(), 'Wer Anders', 'DE02500105170137075030');
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertSame('Mika Muster', $refund->fresh()->account_owner);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Abbruch
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_cancel_marks_the_refund_and_hides_it_from_the_participant(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent());
|
||||
$refund = $this->release($participant)->refund;
|
||||
|
||||
$response = new CancelRefundCommand(new CancelRefundRequest($refund))->execute();
|
||||
|
||||
$this->assertTrue($response->success);
|
||||
$this->assertSame(ParticipantRefund::STATUS_CANCELLED, $refund->fresh()->status);
|
||||
$this->assertNotNull($refund->fresh()->cancelled_at);
|
||||
|
||||
// Für die Anmeldung sieht es danach aus wie vor der Freigabe.
|
||||
$repository = new ParticipantRefundRepository();
|
||||
$this->assertNull($repository->openFor($participant));
|
||||
$this->assertNull($repository->currentFor($participant));
|
||||
}
|
||||
|
||||
public function test_cancel_sends_no_mail(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
Mail::fake();
|
||||
|
||||
new CancelRefundCommand(new CancelRefundRequest($refund))->execute();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_cancel_is_rejected_after_acceptance(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
$this->accept($refund);
|
||||
|
||||
$response = new CancelRefundCommand(new CancelRefundRequest($refund->fresh()))->execute();
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertStringContainsString('bereits bestätigt', $response->message);
|
||||
$this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->fresh()->status);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Der gezahlte Beitrag bleibt unangetastet
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_amount_paid_is_untouched_through_the_whole_process(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent());
|
||||
|
||||
$refund = $this->release($participant)->refund;
|
||||
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
|
||||
|
||||
$this->accept($refund);
|
||||
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
|
||||
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Über HTTP: Routen, Zugriffsschutz und die öffentliche Seite
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
private function makeAdmin(): User
|
||||
{
|
||||
return User::create([
|
||||
'username' => 'al-' . uniqid() . '@example.com',
|
||||
'email' => 'al-' . uniqid() . '@example.com',
|
||||
'firstname' => 'Aktions',
|
||||
'lastname' => 'Leitung',
|
||||
'password' => bcrypt('secret'),
|
||||
'local_group' => $this->tenant->slug,
|
||||
'user_role_main' => UserRole::USER_ROLE_ADMIN,
|
||||
'user_role_local_group' => UserRole::USER_ROLE_USER,
|
||||
'active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_release_over_http_creates_the_refund(): void
|
||||
{
|
||||
$this->actingAs($this->makeAdmin());
|
||||
$participant = $this->makeParticipant($this->makeEvent());
|
||||
|
||||
$response = $this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
|
||||
'amount' => '220,50',
|
||||
'reason' => RefundReason::SICKNESS,
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJsonPath('status', 'success');
|
||||
$response->assertJsonPath('refund.status', ParticipantRefund::STATUS_PENDING);
|
||||
|
||||
$refund = ParticipantRefund::first();
|
||||
$this->assertEqualsWithDelta(220.5, $refund->amount->getAmount(), 0.001);
|
||||
|
||||
// Die Antwort trägt den Vorgang für die Liste -- aber nicht den Token-Link als Bankdaten.
|
||||
$response->assertJsonPath('refund.amountValue', 220.5);
|
||||
}
|
||||
|
||||
public function test_release_requires_a_login(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent());
|
||||
|
||||
$this->post('/api/v1/participant-refund/' . $participant->identifier . '/release', [
|
||||
'amount' => '300',
|
||||
'reason' => RefundReason::SICKNESS,
|
||||
])->assertRedirect('/login');
|
||||
|
||||
$this->assertSame(0, ParticipantRefund::count());
|
||||
}
|
||||
|
||||
public function test_the_public_page_shows_the_open_refund(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
|
||||
$response = $this->get('/rueckerstattung/' . $refund->token);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
// shouldExist=false: die Pages liegen unter app/Domains/**/Views, nicht dort, wo der
|
||||
// Finder von Inertia sucht.
|
||||
->component('ParticipantRefund/Views/RefundPage', false)
|
||||
->where('state', 'open')
|
||||
->where('amount', '300,00 Euro')
|
||||
->where('reason', 'Krankheitsbedingte Absage'));
|
||||
}
|
||||
|
||||
public function test_the_public_page_never_exposes_bank_details(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
$this->accept($refund);
|
||||
|
||||
$response = $this->get('/rueckerstattung/' . $refund->fresh()->token);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page->where('state', 'accepted'));
|
||||
$response->assertDontSee('DE02120300000000202051');
|
||||
}
|
||||
|
||||
public function test_the_public_page_hides_a_cancelled_refund(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
new CancelRefundCommand(new CancelRefundRequest($refund))->execute();
|
||||
|
||||
$this->get('/rueckerstattung/' . $refund->token)
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page->where('state', 'unavailable'));
|
||||
}
|
||||
|
||||
public function test_an_unknown_token_shows_the_same_page_as_a_cancelled_one(): void
|
||||
{
|
||||
$this->get('/rueckerstattung/' . str_repeat('x', 32))
|
||||
->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->where('state', 'unavailable')
|
||||
->where('message', AcceptRefundCommand::NO_OPEN_REFUND));
|
||||
}
|
||||
|
||||
public function test_accept_over_http_works_without_a_login(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
|
||||
$this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [
|
||||
'accountOwner' => 'Mika Muster',
|
||||
'accountIban' => 'DE02 1203 0000 0000 2020 51',
|
||||
'declarationAccepted' => true,
|
||||
])->assertOk()->assertJsonPath('status', 'success');
|
||||
|
||||
$this->assertSame('DE02120300000000202051', $refund->fresh()->account_iban);
|
||||
}
|
||||
|
||||
public function test_field_errors_come_back_with_status_200(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
|
||||
// Der HttpClient des Frontends verwirft Antworten mit Fehlerstatus -- Feldfehler müssen daher
|
||||
// mit 200 zurückkommen, sonst sieht der Teili nie, was er falsch gemacht hat.
|
||||
$this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [
|
||||
'accountOwner' => 'Mika Muster',
|
||||
'accountIban' => 'DE02120300000000202015',
|
||||
'declarationAccepted' => true,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'error')
|
||||
->assertJsonStructure(['error_types' => ['accountIban']]);
|
||||
}
|
||||
|
||||
public function test_the_document_is_not_public(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
$this->accept($refund);
|
||||
|
||||
$this->get('/api/v1/participant-refund/' . $refund->fresh()->token . '/document')
|
||||
->assertRedirect('/login');
|
||||
}
|
||||
|
||||
public function test_the_document_can_be_downloaded_by_the_event_management(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
$this->accept($refund);
|
||||
|
||||
$this->actingAs($this->makeAdmin());
|
||||
|
||||
$response = $this->get('/api/v1/participant-refund/' . $refund->fresh()->token . '/document');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertHeader('Content-Type', 'application/pdf');
|
||||
$this->assertStringStartsWith('%PDF', $response->getContent());
|
||||
}
|
||||
|
||||
public function test_cancel_requires_a_login(): void
|
||||
{
|
||||
$refund = $this->release($this->makeParticipant($this->makeEvent()))->refund;
|
||||
|
||||
$this->post('/api/v1/participant-refund/' . $refund->token . '/cancel')
|
||||
->assertRedirect('/login');
|
||||
|
||||
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Die Mails müssen sich auch wirklich rendern lassen
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_the_release_mail_renders_with_the_link(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent());
|
||||
$refund = $this->release($participant, 220.0, RefundReason::OTHER, 'Umzug')->refund;
|
||||
|
||||
$html = new RefundReleasedMail($participant, $refund)->render();
|
||||
|
||||
$this->assertStringContainsString('220,00 Euro', $html);
|
||||
$this->assertStringContainsString('Sonstiger Grund', $html);
|
||||
$this->assertStringContainsString('Umzug', $html);
|
||||
$this->assertStringContainsString('/rueckerstattung/' . $refund->token, $html);
|
||||
// Der Baustein aus emails.subparts.disclaimer braucht eventTitle und eventEmail.
|
||||
$this->assertStringContainsString('Sommerlager', $html);
|
||||
$this->assertStringContainsString('e@example.com', $html);
|
||||
}
|
||||
|
||||
public function test_the_release_mail_omits_the_note_row_when_there_is_none(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent());
|
||||
$refund = $this->release($participant)->refund;
|
||||
|
||||
$html = new RefundReleasedMail($participant, $refund)->render();
|
||||
|
||||
$this->assertStringNotContainsString('Anmerkung:', $html);
|
||||
}
|
||||
|
||||
public function test_the_acceptance_mail_renders_with_the_bank_details(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent());
|
||||
$refund = $this->release($participant)->refund;
|
||||
$this->accept($refund);
|
||||
|
||||
$html = new RefundAcceptedMail($participant, $refund->fresh(), 'PDF-Inhalt', 'beleg.pdf')->render();
|
||||
|
||||
$this->assertStringContainsString('300,00 Euro', $html);
|
||||
$this->assertStringContainsString('Mika Muster', $html);
|
||||
// Im Anschreiben steht die IBAN in Vierergruppen.
|
||||
$this->assertStringContainsString('DE02 1203 0000 0000 2020 51', $html);
|
||||
$this->assertStringContainsString('Anhang', $html);
|
||||
}
|
||||
|
||||
public function test_the_acceptance_mail_renders_without_a_document(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent());
|
||||
$refund = $this->release($participant)->refund;
|
||||
$this->accept($refund);
|
||||
|
||||
$mail = new RefundAcceptedMail($participant, $refund->fresh());
|
||||
|
||||
$this->assertSame([], $mail->attachments());
|
||||
$this->assertStringNotContainsString('Anhang', $mail->render());
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Gründe als Auswahl fürs Frontend
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_reason_options_are_ordered_and_flag_the_free_text(): void
|
||||
{
|
||||
$options = RefundReason::options();
|
||||
|
||||
$this->assertSame(
|
||||
['sickness', 'event_cancelled', 'other'],
|
||||
array_column($options, 'value')
|
||||
);
|
||||
$this->assertSame('Krankheitsbedingte Absage', $options[0]['label']);
|
||||
$this->assertFalse($options[0]['requiresNote']);
|
||||
$this->assertTrue($options[2]['requiresNote']);
|
||||
}
|
||||
|
||||
public function test_reason_options_are_served_over_http(): void
|
||||
{
|
||||
$this->getJson('/api/v1/core/retrieve-refund-reasons')
|
||||
->assertOk()
|
||||
->assertJsonCount(3)
|
||||
->assertJsonPath('0.value', RefundReason::SICKNESS)
|
||||
->assertJsonPath('2.requiresNote', true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user