Files
mareike/tests/Feature/RefundDocumentTest.php
T

489 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Tests\Feature;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand;
use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentRequest;
use App\Domains\ParticipantRefund\ParticipantRefundTokens;
use App\Enumerations\EfzStatus;
use App\Enumerations\RefundReason;
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\Providers\DocumentTemplateRenderProvider;
use App\RelationModels\EventParticipationFee;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use ReflectionMethod;
use Tests\TestCase;
/**
* Der Erstattungsbeleg: Nummer, Inhalt und der Abgleich zwischen Platzhalter-Katalog und den tatsächlich
* gesetzten Werten.
*/
class RefundDocumentTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::create([
'slug' => 'wm',
'name' => 'Wilde Möhre',
'invoice_sender_name' => 'Wilde Möhre e.V.',
'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' => 'Lommatzsch',
'postcode' => '01623',
'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]);
EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']);
$this->seedTemplate();
}
/** Die Vorlage setzt hier jeden Platzhalter ein, damit die Tests am gerenderten HTML prüfen können. */
private function seedTemplate(): void
{
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_LAYOUT,
'content' => '<div>{block:header_recipient}{block:subject}{block:body}</div>',
'sort_order' => 10,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => 'header_recipient',
'content' => '<p>{recipient_name} / {recipient_address_1} / {recipient_postcode} {recipient_city}</p>',
'sort_order' => 35,
]);
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} / {service_period} / {unregistered_at}</p>'
. '<p>{sender_name} / {recipient_name}</p>',
'sort_order' => 30,
]);
DocumentTemplate::create([
'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND,
'block' => DocumentTemplate::BLOCK_BODY,
'content' => '<p>{intro_text}</p>{details_table}'
. '<p>{refund_amount} / {refund_reason} / {refund_reason_text}</p>'
. '<p>{account_owner} / {account_iban}</p>'
. '<p>{paid_amount} / {invoice_number}</p>'
. '<p>{declaration_text}</p>',
'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));
}
private function makeParticipant(Event $event, array $attributes = []): EventParticipant
{
return $event->participants()->create(array_merge([
'tenant' => $this->tenant->slug,
'identifier' => 'p-' . uniqid(),
'invoice_sequence' => 5,
'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));
}
/** Ein bestätigter Vorgang -- der Beleg entsteht erst dann. */
private function makeRefund(array $attributes = [], array $eventAttributes = []): ParticipantRefund
{
$event = $this->makeEvent($eventAttributes);
$participant = $this->makeParticipant($event);
return ParticipantRefund::create(array_merge([
'tenant' => $this->tenant->slug,
'event_id' => $event->id,
'event_participant_id' => $participant->id,
'token' => str_repeat('a', 32),
'status' => ParticipantRefund::STATUS_ACCEPTED,
'amount' => 300.0,
'reason' => RefundReason::SICKNESS,
'account_owner' => 'Mika Muster',
'account_iban' => 'DE02120300000000202051',
'released_at' => '2026-06-14 10:00:00',
'accepted_at' => '2026-06-18 09:30:00',
], $attributes));
}
private function document(ParticipantRefund $refund, bool $donation = false)
{
return new CreateRefundDocumentCommand(
new CreateRefundDocumentRequest($refund, donation: $donation)
)->execute();
}
/** Das gerenderte HTML -- die Zwischenstufe vor dem PDF, an der sich der Inhalt prüfen lässt. */
private function html(ParticipantRefund $refund, bool $donation = false): string
{
$command = new CreateRefundDocumentCommand(
new CreateRefundDocumentRequest($refund, donation: $donation)
);
$number = new ReflectionMethod($command, 'documentNumber')->invoke($command);
$tokens = new ReflectionMethod($command, 'buildTokens')->invoke($command, $number);
return new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_REFUND)->render($tokens);
}
/*
|--------------------------------------------------------------------------
| Nummer und PDF
|--------------------------------------------------------------------------
*/
public function test_document_number_extends_the_invoice_number(): void
{
$response = $this->document($this->makeRefund());
$this->assertTrue($response->success);
$this->assertSame('WM-V-20260701-0005-R', $response->documentNumber);
$this->assertSame('Rueckerstattung-WM-V-20260701-0005-R.pdf', $response->filename);
}
public function test_a_pdf_is_produced(): void
{
$response = $this->document($this->makeRefund());
$this->assertStringStartsWith('%PDF', $response->pdfContent);
}
public function test_no_document_without_an_invoice_key(): void
{
$response = $this->document($this->makeRefund(eventAttributes: ['invoice_key' => null]));
$this->assertFalse($response->success);
$this->assertStringContainsString('Belegnummer', $response->message);
}
public function test_no_document_before_the_participant_confirmed(): void
{
$response = $this->document($this->makeRefund([
'status' => ParticipantRefund::STATUS_PENDING,
'account_owner' => null,
'account_iban' => null,
'accepted_at' => null,
]));
$this->assertFalse($response->success);
$this->assertStringContainsString('bestätigt', $response->message);
}
/*
|--------------------------------------------------------------------------
| Inhalt
|--------------------------------------------------------------------------
*/
public function test_document_shows_amount_reason_and_bank_details(): void
{
$html = $this->html($this->makeRefund());
$this->assertStringContainsString('300,00', $html);
$this->assertStringContainsString('Krankheitsbedingte Absage', $html);
$this->assertStringContainsString('Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.', $html);
$this->assertStringContainsString('Mika Muster', $html);
// Im Beleg steht die IBAN in Vierergruppen.
$this->assertStringContainsString('DE02 1203 0000 0000 2020 51', $html);
}
public function test_document_shows_event_period_and_cancellation_date(): void
{
$html = $this->html($this->makeRefund());
$this->assertStringContainsString('Sommerlager', $html);
$this->assertStringContainsString('16.07.2026 20.07.2026', $html);
$this->assertStringContainsString('12.06.2026', $html);
// Belegdatum ist der Tag der Bestätigung.
$this->assertStringContainsString('18.06.2026', $html);
}
public function test_the_reason_is_a_row_in_the_table_not_a_block_below_it(): void
{
$refund = $this->makeRefund();
$command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund));
$details = new ReflectionMethod($command, 'renderDetails')->invoke($command);
$this->assertStringContainsString(
'<td class="detail-key">Begründung</td>'
. '<td class="detail-val">Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.</td>',
$details
);
// Der frühere Fließtext-Block unter der Tabelle ist verschwunden.
$this->assertStringNotContainsString('reason-note', $details);
$this->assertStringEndsWith('</table>', $details);
}
public function test_the_reason_row_is_dropped_when_there_is_no_text(): void
{
RefundReason::find(RefundReason::SICKNESS)->update(['document_text' => '']);
$command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($this->makeRefund()));
$details = new ReflectionMethod($command, 'renderDetails')->invoke($command);
// Eine Beschriftung ohne Wert sieht auf einem Beleg nach Fehler aus.
$this->assertStringNotContainsString('Begründung', $details);
$this->assertStringContainsString('Krankheitsbedingte Absage', $details);
}
public function test_the_table_lists_the_rows_in_reading_order(): void
{
$command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($this->makeRefund()));
$details = new ReflectionMethod($command, 'renderDetails')->invoke($command);
preg_match_all('/<td class="detail-key">([^<]+)<\/td>/', $details, $matches);
// Erst wer erklärt, dann worauf sich die Erstattung bezieht, dann warum, dann wohin das Geld geht.
$this->assertSame(
[
'Name',
'Gezahlter Teilnahmebeitrag',
'Rechnung',
'Erstattungsbetrag',
'Grund',
'Begründung',
'Kontoinhaber*in',
'IBAN',
],
$matches[1]
);
}
public function test_the_document_carries_no_contact_details(): void
{
// Für die Abrechnung genügen Name und Anschrift; E-Mail und Telefon gehören nicht auf einen
// Beleg, der durch die Buchhaltung und ins Archiv wandert.
$html = $this->html($this->makeRefund());
$this->assertStringNotContainsString('mika@example.com', $html);
$this->assertStringNotContainsString('0170 0000000', $html);
}
public function test_document_shows_the_paid_amount_next_to_the_refund(): void
{
// Teilerstattung: 220 von 300 gezahlten Euro.
$html = $this->html($this->makeRefund(['amount' => 220.0]));
$this->assertStringContainsString('300,00', $html);
$this->assertStringContainsString('220,00', $html);
// Die Nummer der Teilnahmerechnung, ohne das -R des Belegs.
$this->assertStringContainsString('WM-V-20260701-0005', $html);
}
public function test_document_carries_the_declaration(): void
{
$html = $this->html($this->makeRefund());
$this->assertStringContainsString('Ich versichere, dass ich den genannten Betrag beglichen habe', $html);
}
public function test_the_declaration_comes_from_the_page_text(): void
{
DB::table('page_texts')
->where('name', CreateRefundDocumentCommand::DECLARATION_TEXT)
->update(['content' => 'Eigener Wortlaut des Verbands.']);
$this->assertStringContainsString('Eigener Wortlaut des Verbands.', $this->html($this->makeRefund()));
}
public function test_the_declaration_falls_back_when_the_page_text_is_missing(): void
{
// Ohne Rückfallwert stünde hier ein Fatal Error auf null -- der Beleg muss trotzdem entstehen.
DB::table('page_texts')->where('name', CreateRefundDocumentCommand::DECLARATION_TEXT)->delete();
$this->assertStringContainsString('Ich versichere', $this->html($this->makeRefund()));
}
public function test_the_payout_receipt_carries_the_account_declaration_too(): void
{
// Beide Sätze wurden angekreuzt, beide gehören auf den Beleg: Die Kontoerklärung ist der Grund,
// warum die Auszahlung auf genau dieses Konto zulässig ist.
$html = $this->html($this->makeRefund());
$this->assertStringContainsString('Ich versichere, dass ich den genannten Betrag beglichen habe', $html);
$this->assertStringContainsString('dass das angegebene Konto dasselbe ist', $html);
}
/*
|--------------------------------------------------------------------------
| Der Beleg über eine Spende
|--------------------------------------------------------------------------
*/
public function test_a_donation_receipt_carries_the_waiver_instead(): void
{
$html = $this->html($this->makeRefund(['account_owner' => null, 'account_iban' => null]), donation: true);
$this->assertStringContainsString('verzichte auf die Auszahlung', $html);
// Die Erklärungen des Auszahlungswegs haben hier nichts zu suchen -- es gibt kein Konto.
$this->assertStringNotContainsString('dass das angegebene Konto dasselbe ist', $html);
}
public function test_a_donation_receipt_shows_no_bank_details(): void
{
$html = $this->html($this->makeRefund(['account_owner' => null, 'account_iban' => null]), donation: true);
$this->assertStringNotContainsString('IBAN', $html);
$this->assertStringContainsString('Auf die Auszahlung wird verzichtet', $html);
}
public function test_the_intro_sentence_follows_the_chosen_way(): void
{
$refund = $this->makeRefund();
$this->assertStringContainsString('bitte um die Rückerstattung', $this->html($refund));
$this->assertStringContainsString('spende ihn an den Verband', $this->html($refund, donation: true));
}
public function test_a_donation_receipt_is_a_pdf_as_well(): void
{
$response = $this->document($this->makeRefund(['account_owner' => null, 'account_iban' => null]), donation: true);
$this->assertTrue($response->success);
$this->assertStringStartsWith('%PDF', $response->pdfContent);
}
public function test_free_text_reason_replaces_the_catalog_text(): void
{
$html = $this->html($this->makeRefund([
'reason' => RefundReason::OTHER,
'reason_note' => 'Umzug in ein anderes Bundesland',
]));
$this->assertStringContainsString('Sonstiger Grund', $html);
$this->assertStringContainsString('Umzug in ein anderes Bundesland', $html);
$this->assertStringNotContainsString('krankheitsbedingt', $html);
}
public function test_sender_is_read_live_from_the_tenant(): void
{
$refund = $this->makeRefund();
$this->tenant->update(['invoice_sender_name' => 'Neuer Name e.V.']);
$this->assertStringContainsString('Neuer Name e.V.', $this->html($refund));
}
public function test_single_day_event_shows_one_date(): void
{
$html = $this->html($this->makeRefund(eventAttributes: [
'start_date' => '2026-07-16',
'end_date' => '2026-07-16',
]));
$this->assertStringContainsString('16.07.2026', $html);
$this->assertStringNotContainsString('', $html);
}
/*
|--------------------------------------------------------------------------
| Katalog und Werte müssen dieselben Namen kennen
|--------------------------------------------------------------------------
*/
public function test_token_catalog_matches_the_generated_values(): void
{
$command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($this->makeRefund()));
$tokens = new ReflectionMethod($command, 'buildTokens')->invoke($command, 'WM-V-20260701-0005-R');
$catalog = ParticipantRefundTokens::names();
sort($catalog);
$generated = array_keys($tokens);
sort($generated);
$this->assertSame($catalog, $generated);
}
}