Files
mareike/tests/Feature/RefundRetentionTest.php
T

409 lines
16 KiB
PHP

<?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\RetentionReason;
use App\Enumerations\UserRole;
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
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\Providers\DocumentTemplateRenderProvider;
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 ReflectionMethod;
use Tests\TestCase;
/**
* Der einbehaltene Teil einer Erstattung.
*
* Wird weniger erstattet als gezahlt wurde, bleibt Geld beim Verband. Das muss begründet sein und überall
* sichtbar bleiben -- und `amount_paid` muss es weiter führen, weil die Einnahmenrechnung darauf aufbaut.
*/
class RefundRetentionTest extends TestCase
{
use RefreshDatabase;
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]);
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();
Storage::fake('local');
Mail::fake();
}
/** Die Vorlage setzt den Datenblock ein -- daran lassen sich die Belegzeilen prüfen. */
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>{retained_amount} / {retention_note}</p>',
'sort_order' => 20,
]);
}
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',
// Je Test können mehrere Veranstaltungen entstehen; der Schlüssel ist eindeutig.
'invoice_key' => 'WM-V-2026070' . ($this->sequence + 1),
]);
}
private function makeParticipant(array $attributes = []): EventParticipant
{
$this->sequence++;
$user = User::create([
'username' => 'teili-' . uniqid() . '@example.com',
'email' => 'teili-' . uniqid() . '@example.com',
'firstname' => 'Mika',
'lastname' => 'Muster',
'password' => bcrypt('secret'),
'local_group' => $this->tenant->slug,
'user_role_main' => UserRole::USER_ROLE_USER,
'user_role_local_group' => UserRole::USER_ROLE_USER,
'active' => true,
]);
return $this->makeEvent()->participants()->create(array_merge([
'tenant' => $this->tenant->slug,
'identifier' => 'p-' . uniqid(),
'invoice_sequence' => $this->sequence,
'user_id' => $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));
}
private function release(
EventParticipant $participant,
float $amount,
?string $retentionReason = null,
?string $retentionNote = null,
) {
return new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount($amount, 'Euro'),
reason: RefundReason::SICKNESS,
retentionReason: $retentionReason,
retentionReasonNote: $retentionNote,
))->execute();
}
private function accept(ParticipantRefund $refund): void
{
new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: 'Mika Muster',
accountIban: 'DE02120300000000202051',
declarationAccepted: true,
accountDeclarationAccepted: true,
))->execute();
}
/*
|--------------------------------------------------------------------------
| Der Grund ist Pflicht, sobald etwas bleibt
|--------------------------------------------------------------------------
*/
public function test_a_partial_refund_without_a_reason_is_refused(): void
{
$response = $this->release($this->makeParticipant(), 220.0);
$this->assertFalse($response->success);
$this->assertStringContainsString('einbehalten', $response->message);
$this->assertSame(0, ParticipantRefund::count());
}
public function test_a_full_refund_needs_no_reason(): void
{
$response = $this->release($this->makeParticipant(), 300.0);
$this->assertTrue($response->success);
$this->assertNull($response->refund->retention_reason);
$this->assertEqualsWithDelta(0.0, $response->refund->retained_amount->getAmount(), 0.001);
}
public function test_a_free_text_retention_reason_needs_its_note(): void
{
$response = $this->release($this->makeParticipant(), 220.0, RetentionReason::CUSTOM, ' ');
$this->assertFalse($response->success);
$this->assertStringContainsString('Erläuterung', $response->message);
$this->assertSame(0, ParticipantRefund::count());
}
public function test_an_unknown_retention_reason_is_refused(): void
{
$response = $this->release($this->makeParticipant(), 220.0, 'erfunden');
$this->assertFalse($response->success);
$this->assertSame(0, ParticipantRefund::count());
}
public function test_a_reason_sent_with_a_full_refund_is_not_stored(): void
{
// Im Formular ist das Feld dann gar nicht sichtbar -- ein Wert ohne Bezug gehört nicht in die DB.
$response = $this->release($this->makeParticipant(), 300.0, RetentionReason::CANCELLATION_FEE);
$this->assertTrue($response->success);
$this->assertNull($response->refund->retention_reason);
}
public function test_the_note_is_only_stored_for_reasons_that_require_it(): void
{
$withNote = $this->release($this->makeParticipant(), 220.0, RetentionReason::CUSTOM, 'Bereits gebuchte Bahnfahrt');
$this->assertSame('Bereits gebuchte Bahnfahrt', $withNote->refund->retention_reason_note);
$ignored = $this->release($this->makeParticipant(), 220.0, RetentionReason::MATERIAL, 'wird verworfen');
$this->assertNull($ignored->refund->retention_reason_note);
}
/*
|--------------------------------------------------------------------------
| Was beim Verband bleibt
|--------------------------------------------------------------------------
*/
public function test_the_retained_amount_is_recorded_at_release(): void
{
$response = $this->release($this->makeParticipant(), 220.0, RetentionReason::CANCELLATION_FEE);
// Festgeschrieben, nicht gerechnet: Nach dem Einreichen führt `amount_paid` bereits den Rest.
$this->assertEqualsWithDelta(80.0, $response->refund->retained_amount->getAmount(), 0.001);
$this->assertTrue($response->refund->hasRetention());
}
public function test_amount_paid_keeps_the_retained_share(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant, 220.0, RetentionReason::CANCELLATION_FEE)->refund;
$this->accept($refund);
$this->assertEqualsWithDelta(80.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
}
public function test_a_full_refund_leaves_nothing(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant, 300.0)->refund;
$this->accept($refund);
$this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
}
public function test_a_second_refund_is_capped_at_the_remainder(): void
{
$participant = $this->makeParticipant();
$this->accept($this->release($participant, 220.0, RetentionReason::CANCELLATION_FEE)->refund);
// Es liegen noch 80 € beim Verband -- mehr kann nicht zurückgehen.
$tooMuch = $this->release($participant->fresh(), 100.0, RetentionReason::CANCELLATION_FEE);
$this->assertFalse($tooMuch->success);
$fits = $this->release($participant->fresh(), 80.0);
$this->assertTrue($fits->success);
$this->assertEqualsWithDelta(0.0, $fits->refund->retained_amount->getAmount(), 0.001);
}
/*
|--------------------------------------------------------------------------
| Sichtbarkeit
|--------------------------------------------------------------------------
*/
public function test_the_receipt_names_the_retained_amount_and_reason(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant, 220.0, RetentionReason::CANCELLATION_FEE)->refund;
$this->accept($refund);
$html = $this->renderReceipt($refund->fresh());
$this->assertStringContainsString('Einbehalten', $html);
$this->assertStringContainsString('80,00', $html);
$this->assertStringContainsString('Stornogebühr laut Ausschreibung', $html);
}
public function test_the_receipt_stays_silent_on_a_full_refund(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant, 300.0)->refund;
$this->accept($refund);
$this->assertStringNotContainsString('Einbehalten', $this->renderReceipt($refund->fresh()));
}
public function test_the_mail_explains_the_retention(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant, 220.0, RetentionReason::CUSTOM, 'Bereits gebuchte Bahnfahrt')->refund;
$this->accept($refund);
$html = new RefundAcceptedMail($participant, $refund->fresh())->render();
$this->assertStringContainsString('80,00 Euro', $html);
$this->assertStringContainsString('Sonstiger Grund', $html);
$this->assertStringContainsString('Bereits gebuchte Bahnfahrt', $html);
}
public function test_the_mail_stays_silent_on_a_full_refund(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant, 300.0)->refund;
$this->accept($refund);
$this->assertStringNotContainsString('verbleiben beim Verband', new RefundAcceptedMail($participant, $refund->fresh())->render());
}
public function test_the_resource_carries_the_retention_for_the_lists(): void
{
$participant = $this->makeParticipant();
$refund = $this->release($participant, 220.0, RetentionReason::CANCELLATION_FEE)->refund;
$this->accept($refund);
$data = $refund->fresh()->toResource()->toArray(request());
$this->assertTrue($data['hasRetention']);
$this->assertSame('80,00 Euro', $data['retainedAmount']);
$this->assertSame('Stornogebühr laut Ausschreibung', $data['retentionReasonLabel']);
}
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 DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_REFUND)->render($tokens);
}
}