Calculation errors for refunded amounts
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enumerations\CostUnitType;
|
||||
use App\Enumerations\EfzStatus;
|
||||
use App\Enumerations\InvoiceStatus;
|
||||
use App\Enumerations\InvoiceType;
|
||||
use App\Models\CostUnit;
|
||||
use App\Models\Event;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\PaymentMethod;
|
||||
use App\Models\Tenant;
|
||||
use App\RelationModels\EventParticipationFee;
|
||||
use App\Resources\CostUnitResource;
|
||||
use App\Resources\EventResource;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Die Ausgabenrechnung der Veranstaltungsübersicht.
|
||||
*
|
||||
* Eine Beitragserstattung gehört dort nicht hinein: Sie ist die Rücknahme einer Einnahme, und die
|
||||
* Einnahmenseite hat sie bereits berücksichtigt -- ein abgemeldeter Teili fällt aus `getParticipants()`
|
||||
* heraus. Als Ausgabe gezählt, ginge derselbe Vorgang ein zweites Mal in die Bilanz.
|
||||
*/
|
||||
class EventBudgetTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Tenant $tenant;
|
||||
|
||||
private CostUnit $costUnit;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->tenant = Tenant::create([
|
||||
'slug' => 'wm',
|
||||
'name' => 'Wilde Möhre',
|
||||
'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']);
|
||||
|
||||
// Ein gewöhnlicher Aufwandstyp zum Vergleich; die Beitragserstattung bringt die Migration mit.
|
||||
DB::table('invoice_types')->insert([
|
||||
'slug' => InvoiceType::INVOICE_TYPE_PROGRAM,
|
||||
'name' => 'Programmkosten',
|
||||
'sort_order' => 1,
|
||||
'selectable' => true,
|
||||
'counts_as_expense' => true,
|
||||
]);
|
||||
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
|
||||
return Event::create([
|
||||
'cost_unit_id' => $this->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',
|
||||
'invoice_key' => 'WM-V-20260701',
|
||||
]);
|
||||
}
|
||||
|
||||
private function makeInvoice(string $type, float $amount): Invoice
|
||||
{
|
||||
return Invoice::create([
|
||||
'tenant' => $this->tenant->slug,
|
||||
'cost_unit_id' => $this->costUnit->id,
|
||||
'invoice_number' => '2026-' . str_pad((string) Invoice::count() + 1, 4, '0', STR_PAD_LEFT),
|
||||
'status' => InvoiceStatus::INVOICE_STATUS_NEW,
|
||||
'type' => $type,
|
||||
'donation' => false,
|
||||
'contact_name' => 'Mika Muster',
|
||||
'amount' => $amount,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function costUnitData(): array
|
||||
{
|
||||
return new CostUnitResource($this->costUnit->fresh())->toArray(true);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Die Ausgabenliste
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_a_refund_does_not_appear_as_an_expense_row(): void
|
||||
{
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0);
|
||||
|
||||
$amounts = $this->costUnitData()['amounts'];
|
||||
|
||||
$this->assertArrayNotHasKey(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $amounts);
|
||||
$this->assertArrayHasKey(InvoiceType::INVOICE_TYPE_PROGRAM, $amounts);
|
||||
}
|
||||
|
||||
public function test_a_refund_is_not_part_of_the_expense_total(): void
|
||||
{
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0);
|
||||
|
||||
$this->assertEqualsWithDelta(0.0, $this->costUnitData()['overAllAmount']['value']->getAmount(), 0.001);
|
||||
}
|
||||
|
||||
public function test_an_ordinary_invoice_of_the_same_amount_does_count(): void
|
||||
{
|
||||
// Gegenprobe: Es liegt am Typ, nicht am Betrag.
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 220.0);
|
||||
|
||||
$this->assertEqualsWithDelta(220.0, $this->costUnitData()['overAllAmount']['value']->getAmount(), 0.001);
|
||||
}
|
||||
|
||||
public function test_the_cash_view_still_shows_the_refund(): void
|
||||
{
|
||||
// `totalAmount` speist Kostenstellen-Liste und Dashboard -- dort fließt das Geld tatsächlich ab.
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0);
|
||||
|
||||
$this->assertStringContainsString('220,00', $this->costUnitData()['totalAmount']);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Die Bilanz der Veranstaltung
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_the_balance_is_not_reduced_by_a_refund(): void
|
||||
{
|
||||
// `fresh()`, weil die DB-Vorgaben (Förderung, Höchstbetrag) im frisch erzeugten Model noch nicht
|
||||
// geladen sind und EventResource sie als Amount erwartet.
|
||||
$event = $this->makeEvent()->fresh();
|
||||
$before = new EventResource($event)->toArray(new Request())['totalBalance']['real']['value'];
|
||||
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0);
|
||||
|
||||
$after = new EventResource($event->fresh())->toArray(new Request())['totalBalance']['real']['value'];
|
||||
|
||||
$this->assertEqualsWithDelta($before, $after, 0.001);
|
||||
}
|
||||
|
||||
public function test_the_balance_is_reduced_by_an_ordinary_invoice(): void
|
||||
{
|
||||
// `fresh()`, weil die DB-Vorgaben (Förderung, Höchstbetrag) im frisch erzeugten Model noch nicht
|
||||
// geladen sind und EventResource sie als Amount erwartet.
|
||||
$event = $this->makeEvent()->fresh();
|
||||
$before = new EventResource($event)->toArray(new Request())['totalBalance']['real']['value'];
|
||||
|
||||
$this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 220.0);
|
||||
|
||||
$after = new EventResource($event->fresh())->toArray(new Request())['totalBalance']['real']['value'];
|
||||
|
||||
$this->assertEqualsWithDelta($before - 220.0, $after, 0.001);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Beiträge abgemeldeter Teilis
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function test_money_left_by_unregistered_participants_counts_as_income(): void
|
||||
{
|
||||
$event = $this->makeEvent();
|
||||
$before = new EventResource($event->fresh())->toArray(new Request());
|
||||
|
||||
// 80 € sind nach einer Teilerstattung beim Verband geblieben.
|
||||
$this->makeUnregisteredParticipant($event, 80.0);
|
||||
|
||||
$after = new EventResource($event->fresh())->toArray(new Request());
|
||||
|
||||
$this->assertEqualsWithDelta(80.0, $after['retainedFromUnregistered']['value'], 0.001);
|
||||
|
||||
// In beide Spalten: Das Geld ist da (real) und fließt nicht mehr ab (erwartet).
|
||||
$this->assertEqualsWithDelta(
|
||||
$before['income']['real']['amount']->getAmount() + 80.0,
|
||||
$after['income']['real']['amount']->getAmount(),
|
||||
0.001
|
||||
);
|
||||
$this->assertEqualsWithDelta(
|
||||
$before['income']['expected']['amount']->getAmount() + 80.0,
|
||||
$after['income']['expected']['amount']->getAmount(),
|
||||
0.001
|
||||
);
|
||||
$this->assertEqualsWithDelta(
|
||||
$before['totalBalance']['real']['value'] + 80.0,
|
||||
$after['totalBalance']['real']['value'],
|
||||
0.001
|
||||
);
|
||||
}
|
||||
|
||||
public function test_unregistered_participants_stay_out_of_lists_and_counts(): void
|
||||
{
|
||||
$event = $this->makeEvent();
|
||||
$this->makeUnregisteredParticipant($event, 80.0);
|
||||
|
||||
$participantData = new EventResource($event->fresh())
|
||||
->toArray(new Request())['participants']['participant'];
|
||||
|
||||
// Nur die Geldsumme kommt hinzu -- in Listen und Zahlen bleiben Abgemeldete außen vor.
|
||||
$this->assertSame(0, $participantData['count']);
|
||||
$this->assertArrayNotHasKey('participants', $participantData);
|
||||
$this->assertEqualsWithDelta(0.0, $participantData['amount']['paid']['value'], 0.001);
|
||||
}
|
||||
|
||||
public function test_the_funding_does_not_grow_with_unregistered_participants(): void
|
||||
{
|
||||
$event = $this->makeEvent();
|
||||
$before = new EventResource($event->fresh())->toArray(new Request())['supportPerson']['amount']->getAmount();
|
||||
|
||||
$this->makeUnregisteredParticipant($event, 80.0);
|
||||
|
||||
$after = new EventResource($event->fresh())->toArray(new Request())['supportPerson']['amount']->getAmount();
|
||||
|
||||
// Die wichtigste Gegenprobe: Wer nicht da war, bringt keine Förderung.
|
||||
$this->assertEqualsWithDelta($before, $after, 0.001);
|
||||
}
|
||||
|
||||
public function test_the_row_is_hidden_when_nothing_was_retained(): void
|
||||
{
|
||||
$event = $this->makeEvent();
|
||||
$this->makeUnregisteredParticipant($event, 0.0);
|
||||
|
||||
// Die Zeile in der Übersicht hängt an diesem Wert -- bei 0 soll sie nicht erscheinen.
|
||||
$this->assertEqualsWithDelta(
|
||||
0.0,
|
||||
new EventResource($event->fresh())->toArray(new Request())['retainedFromUnregistered']['value'],
|
||||
0.001
|
||||
);
|
||||
}
|
||||
|
||||
/** Ein abgemeldeter Teili, bei dem der übergebene Betrag beim Verband geblieben ist. */
|
||||
private function makeUnregisteredParticipant(Event $event, float $remaining): void
|
||||
{
|
||||
$event->participants()->create([
|
||||
'tenant' => $this->tenant->slug,
|
||||
'identifier' => 'p-' . uniqid(),
|
||||
'invoice_sequence' => $event->participants()->count() + 1,
|
||||
'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' => $remaining,
|
||||
'unregistered_at' => '2026-06-12',
|
||||
'payment_purpose' => 'Sommerlager',
|
||||
'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
|
||||
'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -90,4 +90,16 @@ class InvoiceTypeSelectionTest extends TestCase
|
||||
InvoiceType::selectable()->pluck('slug')->all()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_the_refund_type_does_not_count_as_an_expense(): void
|
||||
{
|
||||
// Sie mindert die Einnahmenseite bereits -- als Ausgabe gezählt, stünde sie zweimal in der Bilanz.
|
||||
$this->assertSame(
|
||||
[InvoiceType::INVOICE_TYPE_PROGRAM, InvoiceType::INVOICE_TYPE_OTHER],
|
||||
InvoiceType::countingAsExpense()->pluck('slug')->all()
|
||||
);
|
||||
|
||||
$type = InvoiceType::where('slug', InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND)->first();
|
||||
$this->assertFalse($type->counts_as_expense);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Enumerations\CostUnitType;
|
||||
use App\Enumerations\InvoiceStatus;
|
||||
use App\Enumerations\InvoiceType;
|
||||
use App\Enumerations\RefundReason;
|
||||
use App\Enumerations\RetentionReason;
|
||||
use App\Enumerations\UserRole;
|
||||
use App\Mail\ParticipantRefundMails\RefundAcceptedMail;
|
||||
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
|
||||
@@ -206,17 +207,23 @@ class ParticipantRefundTest extends TestCase
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Vorgabewert 300,00 € entspricht dem gezahlten Beitrag -- es bleibt also nichts einbehalten und
|
||||
* es braucht keinen Einbehaltungsgrund. Bei kleineren Beträgen muss einer mitgegeben werden.
|
||||
*/
|
||||
private function release(
|
||||
EventParticipant $participant,
|
||||
float $amount = 300.0,
|
||||
string $reason = RefundReason::SICKNESS,
|
||||
?string $note = null,
|
||||
?string $retentionReason = null,
|
||||
) {
|
||||
return new ReleaseRefundCommand(new ReleaseRefundRequest(
|
||||
participant: $participant,
|
||||
amount: new Amount($amount, 'Euro'),
|
||||
reason: $reason,
|
||||
reasonNote: $note,
|
||||
retentionReason: $retentionReason,
|
||||
))->execute();
|
||||
}
|
||||
|
||||
@@ -296,10 +303,12 @@ class ParticipantRefundTest extends TestCase
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent());
|
||||
|
||||
$response = $this->release($participant, 220.0);
|
||||
$response = $this->release($participant, 220.0, retentionReason: RetentionReason::CANCELLATION_FEE);
|
||||
|
||||
$this->assertTrue($response->success);
|
||||
$this->assertEqualsWithDelta(220.0, $response->refund->amount->getAmount(), 0.001);
|
||||
// Der Rest wird am Vorgang festgeschrieben, nicht später gerechnet.
|
||||
$this->assertEqualsWithDelta(80.0, $response->refund->retained_amount->getAmount(), 0.001);
|
||||
}
|
||||
|
||||
public function test_release_is_rejected_without_an_amount(): void
|
||||
@@ -626,6 +635,8 @@ class ParticipantRefundTest extends TestCase
|
||||
$response = $this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
|
||||
'amount' => '220,50',
|
||||
'reason' => RefundReason::SICKNESS,
|
||||
// Weniger als gezahlt -- der Rest bleibt beim Verband und braucht eine Begründung.
|
||||
'retentionReason' => RetentionReason::CANCELLATION_FEE,
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJsonPath('status', 'success');
|
||||
@@ -768,7 +779,13 @@ class ParticipantRefundTest extends TestCase
|
||||
public function test_the_release_mail_renders_with_the_link(): void
|
||||
{
|
||||
$participant = $this->makeParticipant($this->makeEvent());
|
||||
$refund = $this->release($participant, 220.0, RefundReason::OTHER, 'Umzug')->refund;
|
||||
$refund = $this->release(
|
||||
$participant,
|
||||
220.0,
|
||||
RefundReason::OTHER,
|
||||
'Umzug',
|
||||
RetentionReason::CANCELLATION_FEE
|
||||
)->refund;
|
||||
|
||||
$html = new RefundReleasedMail($participant, $refund)->render();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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\Mail\ParticipantRefundMails\RefundReleasedMail;
|
||||
@@ -204,12 +205,18 @@ class RefundDirectCaptureTest extends TestCase
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/** Freigabe mit bereits bekannter Bankverbindung. */
|
||||
/**
|
||||
* Freigabe mit bereits bekannter Bankverbindung.
|
||||
*
|
||||
* 220 von 300 gezahlten Euro: Es bleibt etwas beim Verband, deshalb gehört ein Einbehaltungsgrund
|
||||
* dazu.
|
||||
*/
|
||||
private function releaseWithBankDetails(
|
||||
?EventParticipant $participant = null,
|
||||
string $owner = 'Mika Muster',
|
||||
string $iban = 'DE02120300000000202051',
|
||||
float $amount = 220.0,
|
||||
?string $retentionReason = RetentionReason::CANCELLATION_FEE,
|
||||
) {
|
||||
return new ReleaseRefundCommand(new ReleaseRefundRequest(
|
||||
participant: $participant ?? $this->makeParticipant(),
|
||||
@@ -217,6 +224,7 @@ class RefundDirectCaptureTest extends TestCase
|
||||
reason: RefundReason::SICKNESS,
|
||||
accountOwner: $owner,
|
||||
accountIban: $iban,
|
||||
retentionReason: $retentionReason,
|
||||
))->execute();
|
||||
}
|
||||
|
||||
@@ -252,7 +260,7 @@ class RefundDirectCaptureTest extends TestCase
|
||||
$this->assertTrue($refund->wasCapturedByManagement());
|
||||
}
|
||||
|
||||
public function test_the_invoice_exists_and_the_paid_amount_is_cleared(): void
|
||||
public function test_the_invoice_exists_and_the_paid_amount_is_settled(): void
|
||||
{
|
||||
$participant = $this->makeParticipant();
|
||||
|
||||
@@ -263,7 +271,19 @@ class RefundDirectCaptureTest extends TestCase
|
||||
$this->assertEqualsWithDelta(220.0, $invoice->amount, 0.001);
|
||||
$this->assertSame($invoice->id, ParticipantRefund::first()->invoice_id);
|
||||
|
||||
// 300 gezahlt, 220 erstattet -- die restlichen 80 bleiben beim Verband und werden dort geführt.
|
||||
$this->assertEqualsWithDelta(80.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
|
||||
}
|
||||
|
||||
public function test_a_full_refund_leaves_nothing_behind(): void
|
||||
{
|
||||
$participant = $this->makeParticipant();
|
||||
|
||||
$this->releaseWithBankDetails($participant, amount: 300.0, retentionReason: null);
|
||||
|
||||
$this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001);
|
||||
$this->assertEqualsWithDelta(0.0, ParticipantRefund::first()->retained_amount->getAmount(), 0.001);
|
||||
$this->assertNull(ParticipantRefund::first()->retention_reason);
|
||||
}
|
||||
|
||||
public function test_only_the_receipt_mail_goes_out(): void
|
||||
@@ -299,6 +319,7 @@ class RefundDirectCaptureTest extends TestCase
|
||||
participant: $this->makeParticipant(),
|
||||
amount: new Amount(220.0, 'Euro'),
|
||||
reason: RefundReason::SICKNESS,
|
||||
retentionReason: RetentionReason::CANCELLATION_FEE,
|
||||
))->execute()->refund;
|
||||
|
||||
$refund->update([
|
||||
@@ -379,6 +400,8 @@ class RefundDirectCaptureTest extends TestCase
|
||||
$this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
|
||||
'amount' => '220,00',
|
||||
'reason' => RefundReason::SICKNESS,
|
||||
// 220 von 300 -- der Rest bleibt beim Verband und braucht eine Begründung.
|
||||
'retentionReason' => RetentionReason::CANCELLATION_FEE,
|
||||
'accountOwner' => 'Mika Muster',
|
||||
'accountIban' => 'DE02 1203 0000 0000 2020 51',
|
||||
])
|
||||
@@ -396,6 +419,8 @@ class RefundDirectCaptureTest extends TestCase
|
||||
$this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [
|
||||
'amount' => '220,00',
|
||||
'reason' => RefundReason::SICKNESS,
|
||||
// 220 von 300 -- der Rest bleibt beim Verband und braucht eine Begründung.
|
||||
'retentionReason' => RetentionReason::CANCELLATION_FEE,
|
||||
'accountOwner' => '',
|
||||
'accountIban' => '',
|
||||
])
|
||||
|
||||
@@ -11,6 +11,7 @@ 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;
|
||||
@@ -200,11 +201,16 @@ class RefundInvoiceTest extends TestCase
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
/** Der ganze Ablauf: Freigabe durch die Aktionsleitung, Bestätigung durch den Teili. */
|
||||
/**
|
||||
* 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());
|
||||
|
||||
@@ -212,6 +218,7 @@ class RefundInvoiceTest extends TestCase
|
||||
participant: $participant,
|
||||
amount: new Amount($amount, 'Euro'),
|
||||
reason: $reason,
|
||||
retentionReason: $retentionReason,
|
||||
))->execute()->refund;
|
||||
|
||||
new AcceptRefundCommand(new AcceptRefundRequest(
|
||||
@@ -359,6 +366,7 @@ class RefundInvoiceTest extends TestCase
|
||||
participant: $participant,
|
||||
amount: new Amount(220.0, 'Euro'),
|
||||
reason: RefundReason::SICKNESS,
|
||||
retentionReason: RetentionReason::CANCELLATION_FEE,
|
||||
))->execute()->refund;
|
||||
|
||||
$response = new AcceptRefundCommand(new AcceptRefundRequest(
|
||||
@@ -403,25 +411,26 @@ class RefundInvoiceTest extends TestCase
|
||||
Mail::assertSent(InvoiceMailsSubmittedConfirmationMail::class);
|
||||
}
|
||||
|
||||
public function test_amount_paid_is_cleared_once_the_invoice_exists(): void
|
||||
public function test_amount_paid_is_settled_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);
|
||||
// 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_cleared(): void
|
||||
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 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.
|
||||
// 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(0.0, $refund->participant->fresh()->amount_paid->getAmount(), 0.001);
|
||||
$this->assertEqualsWithDelta(80.0, $refund->participant->fresh()->amount_paid->getAmount(), 0.001);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
<?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,
|
||||
))->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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user