Refundings without contacting the participant
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
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\RefundAcceptedMail;
|
||||
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
|
||||
use App\Models\CostUnit;
|
||||
use App\Models\DocumentTemplate;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventParticipant;
|
||||
use App\Models\Invoice;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Der Direktweg: Liegt der Aktionsleitung die Bankverbindung bereits vor, entfällt der Umweg über den
|
||||
* Teili -- die Erstattung wird sofort eingereicht.
|
||||
*/
|
||||
class RefundDirectCaptureTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Tenant $tenant;
|
||||
|
||||
private User $management;
|
||||
|
||||
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]);
|
||||
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();
|
||||
|
||||
// Die Aktionsleitung, die freigibt -- sie landet als `captured_by` am Vorgang.
|
||||
$this->management = $this->makeUser('Aktions', 'Leitung', UserRole::USER_ROLE_ADMIN);
|
||||
$this->actingAs($this->management);
|
||||
|
||||
Storage::fake('local');
|
||||
Mail::fake();
|
||||
}
|
||||
|
||||
private function seedTemplate(): void
|
||||
{
|
||||
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>'
|
||||
. '{if:capture_note}<p class="capture-note">{capture_note}</p>{/if:capture_note}',
|
||||
'sort_order' => 20,
|
||||
]);
|
||||
}
|
||||
|
||||
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(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,
|
||||
]);
|
||||
|
||||
$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(array_merge([
|
||||
'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',
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
private function makeParticipant(?Event $event = null, array $attributes = []): EventParticipant
|
||||
{
|
||||
$event ??= $this->makeEvent();
|
||||
$this->sequence++;
|
||||
|
||||
return $event->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,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/** Freigabe mit bereits bekannter Bankverbindung. */
|
||||
private function releaseWithBankDetails(
|
||||
?EventParticipant $participant = null,
|
||||
string $owner = 'Mika Muster',
|
||||
string $iban = 'DE02120300000000202051',
|
||||
float $amount = 220.0,
|
||||
) {
|
||||
return new ReleaseRefundCommand(new ReleaseRefundRequest(
|
||||
participant: $participant ?? $this->makeParticipant(),
|
||||
amount: new Amount($amount, 'Euro'),
|
||||
reason: RefundReason::SICKNESS,
|
||||
accountOwner: $owner,
|
||||
accountIban: $iban,
|
||||
))->execute();
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Der Vorgang ist sofort abgeschlossen
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_the_refund_is_submitted_right_away(): void
|
||||
{
|
||||
$participant = $this->makeParticipant();
|
||||
|
||||
$response = $this->releaseWithBankDetails($participant);
|
||||
|
||||
$this->assertTrue($response->success);
|
||||
$this->assertStringContainsString('eingereicht', $response->message);
|
||||
|
||||
$refund = ParticipantRefund::first();
|
||||
$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_the_capturing_person_is_recorded(): void
|
||||
{
|
||||
$this->releaseWithBankDetails();
|
||||
|
||||
$refund = ParticipantRefund::first();
|
||||
|
||||
$this->assertSame($this->management->id, $refund->captured_by);
|
||||
$this->assertTrue($refund->wasCapturedByManagement());
|
||||
}
|
||||
|
||||
public function test_the_invoice_exists_and_the_paid_amount_is_cleared(): void
|
||||
{
|
||||
$participant = $this->makeParticipant();
|
||||
|
||||
$this->releaseWithBankDetails($participant);
|
||||
|
||||
$invoice = Invoice::first();
|
||||
$this->assertNotNull($invoice);
|
||||
$this->assertEqualsWithDelta(220.0, $invoice->amount, 0.001);
|
||||
$this->assertSame($invoice->id, ParticipantRefund::first()->invoice_id);
|
||||
|
||||
$this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
|
||||
}
|
||||
|
||||
public function test_only_the_receipt_mail_goes_out(): void
|
||||
{
|
||||
$this->releaseWithBankDetails();
|
||||
|
||||
// Es gibt nichts einzutragen -- die Mail mit dem Bestätigungslink wäre sinnlos.
|
||||
Mail::assertNotSent(RefundReleasedMail::class);
|
||||
Mail::assertSent(RefundAcceptedMail::class);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Der Beleg weist aus, wer die Angaben aufgenommen hat
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_the_receipt_names_who_captured_the_details(): void
|
||||
{
|
||||
$this->releaseWithBankDetails();
|
||||
|
||||
$html = $this->renderReceipt(ParticipantRefund::first());
|
||||
|
||||
// Ohne diesen Vermerk läse sich die Erklärung wie eine Bestätigung des Teilis selbst.
|
||||
$this->assertStringContainsString('Angaben aufgenommen durch Aktions Leitung am', $html);
|
||||
$this->assertStringContainsString('Ich versichere', $html);
|
||||
}
|
||||
|
||||
public function test_the_receipt_carries_no_note_when_the_participant_confirmed(): void
|
||||
{
|
||||
// Gegenprobe: der gewöhnliche Weg bleibt unverändert.
|
||||
$refund = new ReleaseRefundCommand(new ReleaseRefundRequest(
|
||||
participant: $this->makeParticipant(),
|
||||
amount: new Amount(220.0, 'Euro'),
|
||||
reason: RefundReason::SICKNESS,
|
||||
))->execute()->refund;
|
||||
|
||||
$refund->update([
|
||||
'status' => ParticipantRefund::STATUS_ACCEPTED,
|
||||
'account_owner' => 'Mika Muster',
|
||||
'account_iban' => 'DE02120300000000202051',
|
||||
'accepted_at' => now(),
|
||||
]);
|
||||
|
||||
$this->assertStringNotContainsString('aufgenommen durch', $this->renderReceipt($refund->fresh()));
|
||||
}
|
||||
|
||||
private function renderReceipt(ParticipantRefund $refund): string
|
||||
{
|
||||
$command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund));
|
||||
$number = new \ReflectionMethod($command, 'documentNumber')->invoke($command);
|
||||
$tokens = new \ReflectionMethod($command, 'buildTokens')->invoke($command, $number);
|
||||
|
||||
return new \App\Providers\DocumentTemplateRenderProvider(
|
||||
DocumentTemplate::TYPE_PARTICIPANT_REFUND
|
||||
)->render($tokens);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Abgelehnte Eingaben -- es gibt keine zweite Gelegenheit zu berichtigen
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_an_invalid_iban_stops_everything(): void
|
||||
{
|
||||
// Gültige Struktur, falsche Prüfziffer -- ein klassischer Zahlendreher.
|
||||
$response = $this->releaseWithBankDetails(iban: 'DE02120300000000202015');
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertStringContainsString('IBAN', $response->message);
|
||||
$this->assertSame(0, ParticipantRefund::count());
|
||||
$this->assertSame(0, Invoice::count());
|
||||
}
|
||||
|
||||
public function test_half_filled_bank_details_are_refused(): void
|
||||
{
|
||||
$response = new ReleaseRefundCommand(new ReleaseRefundRequest(
|
||||
participant: $this->makeParticipant(),
|
||||
amount: new Amount(220.0, 'Euro'),
|
||||
reason: RefundReason::SICKNESS,
|
||||
accountOwner: 'Mika Muster',
|
||||
))->execute();
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertSame(0, ParticipantRefund::count());
|
||||
}
|
||||
|
||||
public function test_without_a_cost_unit_nothing_is_created(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent(['cost_unit_id' => null]));
|
||||
|
||||
$response = $this->releaseWithBankDetails($participant);
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertStringContainsString('Kostenstelle', $response->message);
|
||||
$this->assertSame(0, ParticipantRefund::count());
|
||||
$this->assertSame(0, Invoice::count());
|
||||
// Der gezahlte Beitrag bleibt unangetastet.
|
||||
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Über HTTP
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_release_over_http_submits_directly(): void
|
||||
{
|
||||
$participant = $this->makeParticipant();
|
||||
|
||||
$this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
|
||||
'amount' => '220,00',
|
||||
'reason' => RefundReason::SICKNESS,
|
||||
'accountOwner' => 'Mika Muster',
|
||||
'accountIban' => 'DE02 1203 0000 0000 2020 51',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'success')
|
||||
->assertJsonPath('refund.status', ParticipantRefund::STATUS_ACCEPTED);
|
||||
|
||||
$this->assertNotNull(ParticipantRefund::first()->invoice_id);
|
||||
}
|
||||
|
||||
public function test_release_over_http_without_bank_details_keeps_the_old_way(): void
|
||||
{
|
||||
$participant = $this->makeParticipant();
|
||||
|
||||
$this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
|
||||
'amount' => '220,00',
|
||||
'reason' => RefundReason::SICKNESS,
|
||||
'accountOwner' => '',
|
||||
'accountIban' => '',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('refund.status', ParticipantRefund::STATUS_PENDING);
|
||||
|
||||
Mail::assertSent(RefundReleasedMail::class);
|
||||
$this->assertSame(0, Invoice::count());
|
||||
$this->assertNull(ParticipantRefund::first()->captured_by);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user