Handling SEPA Direct Payment
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Concerns;
|
||||
|
||||
use App\Models\AvailablePaymentMethod;
|
||||
use App\Models\Event;
|
||||
use App\Models\PaymentMethod;
|
||||
use App\RelationModels\EventPaymentMethods;
|
||||
use App\Scopes\SiteScope;
|
||||
|
||||
/**
|
||||
* Bietet bei einer Veranstaltung die Überweisung an -- aktiv beim Mandanten und vollständig konfiguriert.
|
||||
*
|
||||
* Der SignUpCommand nimmt nur eine Zahlungsart an, die der Veranstaltung zugewiesen ist; ein Test, der
|
||||
* eine Anmeldung durchspielt, muss sie deshalb so anlegen, wie es der Betrieb tut. Wiederholte Aufrufe
|
||||
* schaden nicht. Die Zeile in `payment_methods` legt der Test selbst an.
|
||||
*/
|
||||
trait OffersAccountTransfer
|
||||
{
|
||||
protected function offerAccountTransfer(Event $event): void
|
||||
{
|
||||
$slug = PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION;
|
||||
$configuration = ['account_owner' => 'Kasse', 'iban' => 'DE02120300000000202051'];
|
||||
|
||||
AvailablePaymentMethod::withoutGlobalScope(SiteScope::class)->firstOrCreate(
|
||||
['tenant' => $event->tenant, 'slug' => $slug],
|
||||
['name' => 'Überweisung', 'active' => true, 'configuration' => $configuration],
|
||||
);
|
||||
|
||||
EventPaymentMethods::firstOrCreate(
|
||||
['event_id' => $event->id, 'slug' => $slug],
|
||||
['configuration' => $configuration],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use App\ValueObjects\Amount;
|
||||
use DateTime;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\OffersAccountTransfer;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
@@ -28,6 +29,7 @@ use Tests\TestCase;
|
||||
class InvoiceNumberingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
use OffersAccountTransfer;
|
||||
|
||||
private Tenant $wildeMoehre;
|
||||
|
||||
@@ -164,6 +166,8 @@ class InvoiceNumberingTest extends TestCase
|
||||
|
||||
private function signUp(Event $event, string $firstname): void
|
||||
{
|
||||
$this->offerAccountTransfer($event);
|
||||
|
||||
$response = new SignUpCommand(new SignUpRequest(
|
||||
$event, null, $firstname, 'Muster', null, ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
|
||||
$this->wildeMoehre, new DateTime('2000-01-01'), 'Weg 1', null, '00000', 'Stadt',
|
||||
|
||||
@@ -2,14 +2,21 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Domains\Admin\Actions\CreateTenant\CreateTenantAction;
|
||||
use App\Domains\Admin\Actions\CreateTenant\CreateTenantRequest;
|
||||
use App\Domains\Admin\Actions\UpdateAvailablePaymentMethod\UpdateAvailablePaymentMethodAction;
|
||||
use App\Domains\Admin\Actions\UpdateAvailablePaymentMethod\UpdateAvailablePaymentMethodRequest;
|
||||
use App\Domains\Admin\Actions\UpdateAvailablePaymentMethod\UpdateAvailablePaymentMethodResponse;
|
||||
use App\Domains\Admin\Actions\UpdateTenantPayment\UpdateTenantPaymentAction;
|
||||
use App\Domains\Admin\Actions\UpdateTenantPayment\UpdateTenantPaymentRequest;
|
||||
use App\Domains\Event\Actions\SetPaymentMethods\SetPaymentMethodsCommand;
|
||||
use App\Domains\Event\Actions\SetPaymentMethods\SetPaymentMethodsRequest;
|
||||
use App\Models\AvailablePaymentMethod;
|
||||
use App\Models\Event;
|
||||
use App\Models\ParentGroup;
|
||||
use App\Models\PaymentMethod;
|
||||
use App\Models\Tenant;
|
||||
use App\Scopes\SiteScope;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
@@ -43,6 +50,7 @@ class PaymentMethodConfigurationTest extends TestCase
|
||||
|
||||
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]);
|
||||
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]);
|
||||
PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT]);
|
||||
|
||||
DB::table('participation_fee_types')->insert([
|
||||
'slug' => 'FIXED',
|
||||
@@ -127,6 +135,128 @@ class PaymentMethodConfigurationTest extends TestCase
|
||||
$this->assertSame('DE-EVENT', $event->fresh()->paymentMethods()->first()->pivot->configuration['iban']);
|
||||
}
|
||||
|
||||
public function test_sepa_activation_reports_invalid_values(): void
|
||||
{
|
||||
$method = $this->availableMethod(PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT);
|
||||
$config = $this->validSepaConfiguration();
|
||||
|
||||
foreach ([
|
||||
'creditor_id' => ['', 'Pflichtfelder'],
|
||||
'iban' => ['DE02120300000000202015', 'IBAN ist ungültig'],
|
||||
'pre_notification_days' => ['1', 'Vorlauf'],
|
||||
] as $key => [$value, $message]) {
|
||||
$response = $this->updateMethod($method, true, array_merge($config, [$key => $value]));
|
||||
|
||||
$this->assertFalse($response->success, $key);
|
||||
$this->assertStringContainsString($message, $response->message, $key);
|
||||
$this->assertFalse($method->fresh()->active, $key);
|
||||
}
|
||||
|
||||
$response = $this->updateMethod($method, true, array_merge($config, ['creditor_id' => 'DE97ZZZ09999999999']));
|
||||
$this->assertStringContainsString('Gläubiger-ID ist ungültig', $response->message);
|
||||
}
|
||||
|
||||
public function test_sepa_can_be_saved_as_draft_and_activated_once_valid(): void
|
||||
{
|
||||
$method = $this->availableMethod(PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT);
|
||||
|
||||
// Inaktiv darf auch eine unfertige Konfiguration gespeichert werden.
|
||||
$draft = $this->updateMethod($method, false, ['creditor_id' => 'unfertig']);
|
||||
$this->assertTrue($draft->success);
|
||||
$this->assertSame('unfertig', $method->fresh()->configuration['creditor_id']);
|
||||
|
||||
$response = $this->updateMethod($method->fresh(), true, $this->validSepaConfiguration());
|
||||
$this->assertTrue($response->success, $response->message);
|
||||
$this->assertTrue($method->fresh()->active);
|
||||
}
|
||||
|
||||
public function test_tenant_bank_data_is_synced_into_transfer_and_direct_debit(): void
|
||||
{
|
||||
$transfer = $this->availableMethod(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, config: ['icon' => 'coins']);
|
||||
$directDebit = $this->availableMethod(PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT, config: [
|
||||
'creditor_id' => 'DE98ZZZ09999999999',
|
||||
'pre_notification_days' => 7,
|
||||
]);
|
||||
|
||||
new UpdateTenantPaymentAction(new UpdateTenantPaymentRequest(
|
||||
$this->tenant, 'DE02120300000000202051', 'BYLADEM1001', 'Stamm e.V.',
|
||||
))->execute();
|
||||
|
||||
$this->assertSame('DE02120300000000202051', $transfer->fresh()->configuration['iban']);
|
||||
$this->assertSame('coins', $transfer->fresh()->configuration['icon']);
|
||||
|
||||
$config = $directDebit->fresh()->configuration;
|
||||
$this->assertSame('Stamm e.V.', $config['account_owner']);
|
||||
$this->assertSame('DE02120300000000202051', $config['iban']);
|
||||
$this->assertSame('BYLADEM1001', $config['bic']);
|
||||
// Gläubiger-ID und Vorlauf gehören nicht zu den Bankdaten und bleiben stehen.
|
||||
$this->assertSame('DE98ZZZ09999999999', $config['creditor_id']);
|
||||
$this->assertSame(7, $config['pre_notification_days']);
|
||||
}
|
||||
|
||||
public function test_event_assignment_rejects_an_incomplete_configuration(): void
|
||||
{
|
||||
$this->availableMethod(PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT, true, $this->validSepaConfiguration());
|
||||
$event = $this->createEvent();
|
||||
|
||||
$response = new SetPaymentMethodsCommand(new SetPaymentMethodsRequest(
|
||||
$event,
|
||||
[PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT],
|
||||
[PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT => array_merge($this->validSepaConfiguration(), ['creditor_id' => 'DE97ZZZ09999999999'])],
|
||||
))->execute();
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertStringContainsString('SEPA-Lastschrift', $response->message);
|
||||
$this->assertSame(0, $event->paymentMethods()->count()); // nichts geschrieben
|
||||
|
||||
// Ohne Override greift der (gültige) Snapshot der Tenant-Config.
|
||||
$this->runUpdateEvent($event, [PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT]);
|
||||
$this->assertSame(
|
||||
'DE98ZZZ09999999999',
|
||||
$event->fresh()->paymentMethods()->first()->pivot->configuration['creditor_id'],
|
||||
);
|
||||
}
|
||||
|
||||
public function test_new_tenant_gets_payment_methods_inactive_until_configured(): void
|
||||
{
|
||||
$parentGroup =ParentGroup::where('is_fallback', true)->first()
|
||||
?? ParentGroup::create(['name' => 'Ohne Zuordnung', 'is_fallback' => true]);
|
||||
|
||||
$response = new CreateTenantAction(new CreateTenantRequest(
|
||||
name: 'Neu', slug: 'neu', url: 'neu.local', parentGroup: $parentGroup,
|
||||
))->execute();
|
||||
|
||||
$this->assertTrue($response->success, $response->message);
|
||||
|
||||
$methods = AvailablePaymentMethod::withoutGlobalScope(SiteScope::class)->where('tenant', 'neu')->get();
|
||||
$this->assertCount(3, $methods);
|
||||
$this->assertTrue($methods->every(fn (AvailablePaymentMethod $method) => !$method->active));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function validSepaConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'account_owner' => 'Test e.V.',
|
||||
'iban' => 'DE02120300000000202051',
|
||||
'bic' => '',
|
||||
'creditor_id' => 'DE98ZZZ09999999999',
|
||||
'pre_notification_days' => '5',
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $configuration */
|
||||
private function updateMethod(AvailablePaymentMethod $method, bool $active, array $configuration): UpdateAvailablePaymentMethodResponse
|
||||
{
|
||||
return new UpdateAvailablePaymentMethodAction(new UpdateAvailablePaymentMethodRequest(
|
||||
availablePaymentMethod: $method,
|
||||
name: 'SEPA-Lastschrift',
|
||||
description: null,
|
||||
active: $active,
|
||||
configuration: $configuration,
|
||||
))->execute();
|
||||
}
|
||||
|
||||
private function createEvent(): Event
|
||||
{
|
||||
return Event::create([
|
||||
|
||||
@@ -22,11 +22,13 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\Concerns\OffersAccountTransfer;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ShortSignUpTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
use OffersAccountTransfer;
|
||||
|
||||
private Tenant $tenant;
|
||||
|
||||
@@ -84,6 +86,8 @@ class ShortSignUpTest extends TestCase
|
||||
$event->refresh();
|
||||
}
|
||||
|
||||
$this->offerAccountTransfer($event);
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Models\Tenant;
|
||||
use App\ValueObjects\Amount;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\OffersAccountTransfer;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,7 @@ use Tests\TestCase;
|
||||
class SignUpEatingHabitTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
use OffersAccountTransfer;
|
||||
|
||||
private Tenant $tenant;
|
||||
|
||||
@@ -60,6 +62,7 @@ class SignUpEatingHabitTest extends TestCase
|
||||
'pay_per_day' => false, 'pay_direct' => false,
|
||||
]);
|
||||
$event->eatingHabits()->attach(EatingHabit::whereIn('slug', $eatingHabitSlugs)->pluck('id')->all());
|
||||
$this->offerAccountTransfer($event);
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
@@ -13,15 +13,18 @@ use App\Models\AvailablePaymentMethod;
|
||||
use App\Models\Event;
|
||||
use App\Models\PaymentMethod;
|
||||
use App\Models\Tenant;
|
||||
use App\RelationModels\EventPaymentMethods;
|
||||
use App\Resources\AvailablePaymentMethodResource;
|
||||
use App\ValueObjects\Amount;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\OffersAccountTransfer;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SignUpPaymentOptionsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
use OffersAccountTransfer;
|
||||
|
||||
private Tenant $tenant;
|
||||
|
||||
@@ -75,6 +78,7 @@ class SignUpPaymentOptionsTest extends TestCase
|
||||
public function test_payment_options_are_sanitized_and_stored(): void
|
||||
{
|
||||
$event = $this->createEvent();
|
||||
$this->offerAccountTransfer($event);
|
||||
|
||||
// Überweisung hat keine Teilnehmer-Eingaben -> alles wird verworfen, payment_options bleibt leer.
|
||||
$response = new SignUpCommand($this->signUpRequest($event, ['evil' => 'x']))->execute();
|
||||
@@ -85,6 +89,38 @@ class SignUpPaymentOptionsTest extends TestCase
|
||||
$this->assertStringContainsString('Beitrag Hans Muster', $response->participant->payment_purpose);
|
||||
}
|
||||
|
||||
public function test_sign_up_is_rejected_for_a_payment_method_not_offered_by_the_event(): void
|
||||
{
|
||||
$event = $this->createEvent();
|
||||
AvailablePaymentMethod::create([
|
||||
'tenant' => $this->tenant->slug, 'slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION,
|
||||
'name' => 'Überweisung', 'active' => true, 'configuration' => ['account_owner' => 'Kasse', 'iban' => 'DE1'],
|
||||
]);
|
||||
|
||||
// Beim Mandanten aktiv, aber der Veranstaltung nicht zugewiesen.
|
||||
$response = new SignUpCommand($this->signUpRequest($event, []))->execute();
|
||||
|
||||
$this->assertFalse($response->success);
|
||||
$this->assertSame(0, $event->participants()->count());
|
||||
}
|
||||
|
||||
public function test_sign_up_is_rejected_when_the_method_is_inactive_or_incomplete(): void
|
||||
{
|
||||
$event = $this->createEvent();
|
||||
$this->offerAccountTransfer($event);
|
||||
|
||||
// Zugewiesen, aber am Event unvollständig konfiguriert (z.B. per Override ohne IBAN).
|
||||
EventPaymentMethods::where('event_id', $event->id)->update(['configuration' => json_encode(['account_owner' => 'Kasse'])]);
|
||||
$this->assertFalse(new SignUpCommand($this->signUpRequest($event, []))->execute()->success);
|
||||
|
||||
// Vollständig, aber beim Mandanten deaktiviert.
|
||||
EventPaymentMethods::where('event_id', $event->id)->update(['configuration' => json_encode(['account_owner' => 'Kasse', 'iban' => 'DE1'])]);
|
||||
AvailablePaymentMethod::where('slug', PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION)->update(['active' => false]);
|
||||
$this->assertFalse(new SignUpCommand($this->signUpRequest($event, []))->execute()->success);
|
||||
|
||||
$this->assertSame(0, $event->participants()->count());
|
||||
}
|
||||
|
||||
public function test_resource_exposes_participant_options_schema(): void
|
||||
{
|
||||
$event = $this->createEvent();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Support\CreditorId;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class CreditorIdTest extends TestCase
|
||||
{
|
||||
public function test_normalizes_spaces_and_case(): void
|
||||
{
|
||||
$this->assertSame('DE98ZZZ09999999999', CreditorId::normalize(' de98 zzz 0999 9999 999 '));
|
||||
}
|
||||
|
||||
public function test_accepts_the_bundesbank_test_id(): void
|
||||
{
|
||||
$this->assertTrue(CreditorId::isValid('DE98ZZZ09999999999'));
|
||||
$this->assertTrue(CreditorId::isValid('de98 zzz 09999999999'));
|
||||
}
|
||||
|
||||
public function test_business_code_does_not_affect_the_check_digit(): void
|
||||
{
|
||||
// Die Geschäftsbereichskennung (Stellen 5-7) wählt der Gläubiger selbst -- die Prüfziffer bleibt.
|
||||
$this->assertTrue(CreditorId::isValid('DE98ABC09999999999'));
|
||||
}
|
||||
|
||||
public function test_rejects_a_wrong_check_digit(): void
|
||||
{
|
||||
$this->assertFalse(CreditorId::isValid('DE97ZZZ09999999999'));
|
||||
$this->assertFalse(CreditorId::isValid('DE98ZZZ09999999998'));
|
||||
}
|
||||
|
||||
public function test_rejects_a_wrong_german_length(): void
|
||||
{
|
||||
$this->assertFalse(CreditorId::isValid('DE98ZZZ0999999999'));
|
||||
$this->assertFalse(CreditorId::isValid('DE98ZZZ099999999990'));
|
||||
}
|
||||
|
||||
public function test_rejects_malformed_input(): void
|
||||
{
|
||||
$this->assertFalse(CreditorId::isValid(''));
|
||||
$this->assertFalse(CreditorId::isValid('DE02120300000000202051')); // eine IBAN
|
||||
$this->assertFalse(CreditorId::isValid('DEXXZZZ09999999999'));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace Tests\Unit;
|
||||
|
||||
use App\EventPaymentModules\EventPaymentModuleRegistry;
|
||||
use App\EventPaymentModules\Modules\AccountTransferPaymentModule;
|
||||
use App\EventPaymentModules\Modules\SepaDirectDebitPaymentModule;
|
||||
use App\EventPaymentModules\Modules\UndefinedPaymentModule;
|
||||
use App\EventPaymentModules\ProvidesGiroCode;
|
||||
use App\Models\PaymentMethod;
|
||||
@@ -21,6 +22,55 @@ class EventPaymentModuleRegistryTest extends TestCase
|
||||
UndefinedPaymentModule::class,
|
||||
EventPaymentModuleRegistry::forSlug(PaymentMethod::PAYMENT_NOT_DEFINED)
|
||||
);
|
||||
$this->assertInstanceOf(
|
||||
SepaDirectDebitPaymentModule::class,
|
||||
EventPaymentModuleRegistry::forSlug(PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_sepa_direct_debit_requires_a_valid_configuration(): void
|
||||
{
|
||||
$module = new SepaDirectDebitPaymentModule();
|
||||
$valid = [
|
||||
'account_owner' => 'Test e.V.',
|
||||
'iban' => 'DE02120300000000202051',
|
||||
'creditor_id' => 'DE98ZZZ09999999999',
|
||||
'pre_notification_days' => '5',
|
||||
];
|
||||
|
||||
$this->assertSame(['account_owner', 'iban', 'creditor_id', 'pre_notification_days'], $module->requiredOptionKeys());
|
||||
$this->assertSame([], $module->configurationErrors($valid));
|
||||
$this->assertTrue($module->isConfigurationComplete($valid));
|
||||
|
||||
// Befüllt, aber ungültig -- reicht nicht.
|
||||
foreach ([
|
||||
'iban' => 'DE02120300000000202015',
|
||||
'creditor_id' => 'DE97ZZZ09999999999',
|
||||
'pre_notification_days' => '1',
|
||||
] as $key => $value) {
|
||||
$config = array_merge($valid, [$key => $value]);
|
||||
$this->assertArrayHasKey($key, $module->configurationErrors($config), $key);
|
||||
$this->assertFalse($module->isConfigurationComplete($config), $key);
|
||||
}
|
||||
|
||||
foreach (['15', '2.5', 'fünf'] as $days) {
|
||||
$this->assertArrayHasKey('pre_notification_days', $module->configurationErrors(
|
||||
array_merge($valid, ['pre_notification_days' => $days])
|
||||
), $days);
|
||||
}
|
||||
|
||||
// Leere Felder sind Sache der Pflichtfeld-Prüfung, nicht der Inhaltsprüfung.
|
||||
$this->assertSame([], $module->configurationErrors([]));
|
||||
$this->assertFalse($module->isConfigurationComplete(array_merge($valid, ['creditor_id' => ''])));
|
||||
}
|
||||
|
||||
public function test_sepa_direct_debit_defaults(): void
|
||||
{
|
||||
$defaults = PaymentMethod::defaults()[PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT];
|
||||
|
||||
$this->assertSame('SEPA-Lastschrift', $defaults['name']);
|
||||
$this->assertSame(5, $defaults['configuration']['pre_notification_days']);
|
||||
$this->assertSame('file-signature', $defaults['configuration']['icon']);
|
||||
}
|
||||
|
||||
public function test_for_slug_returns_null_for_unknown(): void
|
||||
@@ -34,6 +84,7 @@ class EventPaymentModuleRegistryTest extends TestCase
|
||||
|
||||
$this->assertContains(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, $slugs);
|
||||
$this->assertContains(PaymentMethod::PAYMENT_NOT_DEFINED, $slugs);
|
||||
$this->assertContains(PaymentMethod::PAYMENT_SEPA_DIRECT_DEBIT, $slugs);
|
||||
}
|
||||
|
||||
public function test_module_option_helpers(): void
|
||||
|
||||
Reference in New Issue
Block a user