Files
mareike/tests/Feature/RefundInvoiceTest.php
T

498 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\AcceptRefund\AcceptRefundCommand;
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
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\InvoiceType;
use App\Enumerations\RefundReason;
use App\Enumerations\RetentionReason;
use App\Enumerations\UserRole;
use App\Mail\InvoiceMails\InvoiceMailsSubmittedConfirmationMail;
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 Anschluss an die Buchhaltung: aus einer bestätigten Erstattung wird eine gewöhnliche
* Auslagenabrechnung, die den regulären Weg bis zur SEPA-Datei geht.
*/
class RefundInvoiceTest 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]);
// Ein gewöhnlicher Aufwandstyp zum Vergleich; die Beitragserstattung bringt die Migration mit.
DB::table('invoice_types')->insert([
'slug' => InvoiceType::INVOICE_TYPE_TRAVELLING,
'name' => 'Fahrtkosten',
'sort_order' => 1,
'selectable' => true,
'counts_as_expense' => true,
]);
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();
// Der Beleg wird als Datei abgelegt -- ohne Fake landete er im echten Storage.
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' => '<div>{recipient_name}</div>{details_table}',
'sort_order' => 20,
]);
}
private function makeCostUnit(array $attributes = []): CostUnit
{
return CostUnit::create(array_merge([
'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,
], $attributes));
}
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([
'cost_unit_id' => $this->makeCostUnit()->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 makeUser(): User
{
return 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,
]);
}
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,
'user_id' => $this->makeUser()->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));
}
/**
* Der ganze Ablauf: Freigabe durch die Aktionsleitung, Bestätigung durch den Teili.
*
* 220 von 300 gezahlten Euro -- es bleibt etwas beim Verband, deshalb der Einbehaltungsgrund.
*/
private function runRefund(
?EventParticipant $participant = null,
float $amount = 220.0,
string $reason = RefundReason::SICKNESS,
?string $retentionReason = RetentionReason::CANCELLATION_FEE,
): ParticipantRefund {
$participant ??= $this->makeParticipant($this->makeEvent());
$refund = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount($amount, 'Euro'),
reason: $reason,
retentionReason: $retentionReason,
))->execute()->refund;
new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: 'Mika Muster',
accountIban: 'DE02120300000000202051',
declarationAccepted: true,
))->execute();
return $refund->fresh();
}
/*
|--------------------------------------------------------------------------
| Die Abrechnung entsteht
|--------------------------------------------------------------------------
*/
public function test_confirming_creates_an_invoice_on_the_events_cost_unit(): void
{
$event = $this->makeEvent();
$this->runRefund($this->makeParticipant($event));
$invoice = Invoice::first();
$this->assertNotNull($invoice);
$this->assertSame(InvoiceStatus::INVOICE_STATUS_NEW, $invoice->status);
$this->assertSame(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $invoice->type);
$this->assertSame($event->cost_unit_id, $invoice->cost_unit_id);
$this->assertEqualsWithDelta(220.0, $invoice->amount, 0.001);
$this->assertFalse((bool) $invoice->donation);
}
public function test_contact_details_come_from_the_participant(): void
{
$this->runRefund();
$invoice = Invoice::first();
$this->assertSame('Mika Muster', $invoice->contact_name);
$this->assertSame('mika@example.com', $invoice->contact_email);
$this->assertSame('0170 0000000', $invoice->contact_phone);
}
public function test_bank_details_come_from_the_refund_not_the_participant(): void
{
$this->runRefund();
$invoice = Invoice::first();
$this->assertSame('Mika Muster', $invoice->contact_bank_owner);
$this->assertSame('DE02120300000000202051', $invoice->contact_bank_iban);
}
public function test_the_invoice_belongs_to_the_participants_user_account(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$this->runRefund($participant);
$invoice = Invoice::first();
$this->assertSame($participant->user_id, $invoice->user_id);
// Ohne das bliebe die Verknüpfung wirkungslos: CreateInvoiceCommand verwirft die user_id,
// sobald ein Verwendungszweck gesetzt ist.
$this->assertNull($invoice->payment_purpose);
}
public function test_an_anonymous_participant_still_gets_an_invoice(): void
{
$participant = $this->makeParticipant($this->makeEvent(), ['user_id' => null]);
$this->runRefund($participant);
$invoice = Invoice::first();
$this->assertNotNull($invoice);
$this->assertNull($invoice->user_id);
}
public function test_the_notice_names_event_reason_and_the_amount_paid(): void
{
$this->runRefund();
// Der gezahlte Beitrag gehört in die Anmerkung, weil er am Teilnehmer gleich auf 0 gesetzt wird --
// sonst könnte die Schatzmeisterei den Vorgang nicht mehr nachvollziehen.
$this->assertSame(
'Rückerstattung Teilnahmebeitrag Sommerlager Krankheitsbedingte Absage'
. ' | Gezahlter Beitrag vor Erstattung: 300,00 Euro',
Invoice::first()->comment
);
}
public function test_a_long_notice_keeps_the_amount_paid(): void
{
$event = $this->makeEvent(['name' => str_repeat('Sehr langer Veranstaltungsname ', 12)]);
$this->runRefund($this->makeParticipant($event));
$comment = Invoice::first()->comment;
// Gekürzt wird nur der freie Teil -- der Betrag darf nie wegfallen.
$this->assertStringContainsString('Gezahlter Beitrag vor Erstattung: 300,00 Euro', $comment);
$this->assertLessThanOrEqual(255, strlen($comment), 'Die Spalte `comment` fasst 255 Zeichen.');
}
/*
|--------------------------------------------------------------------------
| Der Eigenbeleg hängt an der Abrechnung
|--------------------------------------------------------------------------
*/
public function test_the_receipt_is_attached_and_stored(): void
{
$event = $this->makeEvent();
$this->runRefund($this->makeParticipant($event));
$invoice = Invoice::first();
$this->assertNotNull($invoice->document_filename);
// Dasselbe Ablagemuster wie bei hochgeladenen Belegen.
$this->assertStringStartsWith('wm/invoices/' . $event->cost_unit_id . '/', $invoice->document_filename);
$this->assertStringEndsWith('.pdf', $invoice->document_filename);
Storage::disk('local')->assertExists($invoice->document_filename);
$this->assertStringStartsWith('%PDF', Storage::disk('local')->get($invoice->document_filename));
}
public function test_the_refund_points_at_its_invoice(): void
{
$refund = $this->runRefund();
$this->assertSame(Invoice::first()->id, $refund->invoice_id);
$this->assertSame(Invoice::first()->invoice_number, $refund->invoice()->first()->invoice_number);
}
/*
|--------------------------------------------------------------------------
| Die Kostenstelle
|--------------------------------------------------------------------------
*/
public function test_without_a_cost_unit_nothing_is_stored(): void
{
$participant = $this->makeParticipant($this->makeEvent(['cost_unit_id' => null]));
$refund = new ReleaseRefundCommand(new ReleaseRefundRequest(
participant: $participant,
amount: new Amount(220.0, 'Euro'),
reason: RefundReason::SICKNESS,
retentionReason: RetentionReason::CANCELLATION_FEE,
))->execute()->refund;
$response = new AcceptRefundCommand(new AcceptRefundRequest(
refund: $refund,
accountOwner: 'Mika Muster',
accountIban: 'DE02120300000000202051',
declarationAccepted: true,
))->execute();
$this->assertFalse($response->success);
$this->assertStringContainsString('Aktionsleitung', $response->message);
// Der Vorgang bleibt offen, damit die Aktionsleitung nachbessern kann.
$refund->refresh();
$this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->status);
$this->assertNull($refund->account_iban);
$this->assertSame(0, Invoice::count());
}
public function test_a_closed_cost_unit_is_still_booked(): void
{
// Erstattungen fallen oft erst an, wenn die Abrechnungsfrist der Veranstaltung längst durch ist.
$costUnit = $this->makeCostUnit(['allow_new' => false]);
$event = $this->makeEvent(['cost_unit_id' => $costUnit->id]);
$this->runRefund($this->makeParticipant($event));
$this->assertSame($costUnit->id, Invoice::first()->cost_unit_id);
}
/*
|--------------------------------------------------------------------------
| Der reguläre Weg läuft mit
|--------------------------------------------------------------------------
*/
public function test_the_standard_confirmation_mail_is_sent(): void
{
$this->runRefund();
// Die Quittung des Abrechnungssystems -- zusätzlich zu unserer Mail mit dem Beleg.
Mail::assertSent(InvoiceMailsSubmittedConfirmationMail::class);
}
public function test_amount_paid_is_settled_once_the_invoice_exists(): void
{
$participant = $this->makeParticipant($this->makeEvent());
$this->runRefund($participant);
// 300 gezahlt, 220 erstattet: `amount_paid` führt danach den Rest, der beim Verband bleibt.
$this->assertEqualsWithDelta(80.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
// Der Sollbetrag bleibt: was der Teili hätte zahlen müssen, ändert die Erstattung nicht.
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001);
}
public function test_the_receipt_is_written_before_the_amount_is_settled(): void
{
$refund = $this->runRefund();
// Reihenfolge-Falle: Beleg und Anmerkung weisen den gezahlten Beitrag aus. Wird zu früh
// verrechnet, stünden dort 80,00 € statt 300,00 €. Der Beleg selbst liegt als PDF vor;
// nachprüfbar ist die Reihenfolge an der Anmerkung, die im selben Schritt entsteht.
$this->assertStringContainsString('300,00 Euro', Invoice::first()->comment);
$this->assertEqualsWithDelta(80.0, $refund->participant->fresh()->amount_paid->getAmount(), 0.001);
}
/*
|--------------------------------------------------------------------------
| Der Verwendungszweck der Überweisung
|--------------------------------------------------------------------------
*/
public function test_the_payment_purpose_of_a_refund_names_the_refund(): void
{
$this->runRefund();
$invoice = Invoice::first();
// Auf dem Kontoauszug des Teilis muss der Vorgang stehen, den es gab: Er hatte keine Auslage,
// er bekommt seinen Beitrag zurück.
$this->assertSame(
'Beitragserstattung Belegnummer ' . $invoice->invoice_number,
$invoice->paymentPurposeText()
);
}
public function test_an_ordinary_invoice_keeps_the_expense_wording(): void
{
$invoice = Invoice::create([
'tenant' => $this->tenant->slug,
'cost_unit_id' => $this->makeCostUnit()->id,
'invoice_number' => '2026-0042',
'status' => InvoiceStatus::INVOICE_STATUS_NEW,
'type' => InvoiceType::INVOICE_TYPE_TRAVELLING,
'contact_name' => 'Mika Muster',
'amount' => 42.0,
]);
$this->assertSame('Auslagenerstattung Belegnummer 2026-0042', $invoice->paymentPurposeText());
}
public function test_a_free_text_purpose_wins(): void
{
$invoice = Invoice::create([
'tenant' => $this->tenant->slug,
'cost_unit_id' => $this->makeCostUnit()->id,
'invoice_number' => '2026-0043',
'status' => InvoiceStatus::INVOICE_STATUS_NEW,
'type' => InvoiceType::INVOICE_TYPE_TRAVELLING,
'contact_name' => 'Mika Muster',
'amount' => 42.0,
'payment_purpose' => 'Sommerlager',
]);
$this->assertSame('Sommerlager', $invoice->paymentPurposeText());
}
}