489 lines
19 KiB
PHP
489 lines
19 KiB
PHP
<?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);
|
||
}
|
||
}
|