428 lines
16 KiB
PHP
428 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\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\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]);
|
||
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. */
|
||
private function runRefund(
|
||
?EventParticipant $participant = null,
|
||
float $amount = 220.0,
|
||
string $reason = RefundReason::SICKNESS,
|
||
): ParticipantRefund {
|
||
$participant ??= $this->makeParticipant($this->makeEvent());
|
||
|
||
$refund = new ReleaseRefundCommand(new ReleaseRefundRequest(
|
||
participant: $participant,
|
||
amount: new Amount($amount, 'Euro'),
|
||
reason: $reason,
|
||
))->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,
|
||
))->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_cleared_once_the_invoice_exists(): void
|
||
{
|
||
$participant = $this->makeParticipant($this->makeEvent());
|
||
$this->runRefund($participant);
|
||
|
||
// Mit der eingereichten Abrechnung ist der Beitrag auf dem Weg zurück und darf in den
|
||
// Zahlungsübersichten nicht länger als eingegangen stehen.
|
||
$this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
|
||
$this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001);
|
||
}
|
||
|
||
public function test_the_receipt_is_written_before_the_amount_is_cleared(): void
|
||
{
|
||
$refund = $this->runRefund();
|
||
|
||
// Reihenfolge-Falle: Beleg und Anmerkung weisen den gezahlten Beitrag aus. Wird zu früh genullt,
|
||
// stünde dort 0,00 €. Der Beleg selbst liegt als PDF vor; nachprüfbar ist die Reihenfolge an der
|
||
// Anmerkung, die im selben Schritt und aus derselben Quelle entsteht.
|
||
$this->assertStringContainsString('300,00 Euro', Invoice::first()->comment);
|
||
$this->assertEqualsWithDelta(0.0, $refund->participant->fresh()->amount_paid->getAmount(), 0.001);
|
||
}
|
||
}
|