mirror of
https://github.com/wp-graphql/wp-graphql-woocommerce.git
synced 2026-08-14 12:53:44 +02:00
feat: Implements refund mutations (#906)
* chore: New feature stubbed out * feat: Implement createRefund and deleteRefund mutations createRefund: Creates a refund on an order with amount, reason, optional payment gateway refund, restock, and meta data support. Requires edit_shop_orders capability. deleteRefund: Deletes a refund by ID with optional force flag. Returns the deleted refund data and parent order. Requires delete_shop_orders capability. Both mutations include before/after action hooks for extensibility and follow the WC REST API refund controller pattern. Closes #17 * chore: Add PHPStan type annotation for wc_get_order in Refund_Delete * devops: Add guard tests for deleteRefund mutation Test invalid refund ID, order ID passed instead of refund ID, with expectedErrorMessage assertions confirming error messages.
This commit is contained in:
@@ -211,6 +211,8 @@ class Type_Registry {
|
||||
Mutation\Product_Variation_Create::register_mutation();
|
||||
Mutation\Product_Variation_Delete::register_mutation();
|
||||
Mutation\Product_Variation_Update::register_mutation();
|
||||
Mutation\Refund_Create::register_mutation();
|
||||
Mutation\Refund_Delete::register_mutation();
|
||||
Mutation\Review_Delete_Restore::register_mutation();
|
||||
Mutation\Review_Update::register_mutation();
|
||||
Mutation\Review_Write::register_mutation();
|
||||
|
||||
@@ -373,6 +373,8 @@ if ( ! class_exists( '\WPGraphQL\WooCommerce\WP_GraphQL_WooCommerce' ) ) :
|
||||
require $include_directory_path . 'mutation/class-product-variation-create.php';
|
||||
require $include_directory_path . 'mutation/class-product-variation-delete.php';
|
||||
require $include_directory_path . 'mutation/class-product-variation-update.php';
|
||||
require $include_directory_path . 'mutation/class-refund-create.php';
|
||||
require $include_directory_path . 'mutation/class-refund-delete.php';
|
||||
require $include_directory_path . 'mutation/class-review-delete-restore.php';
|
||||
require $include_directory_path . 'mutation/class-review-update.php';
|
||||
require $include_directory_path . 'mutation/class-review-write.php';
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
/**
|
||||
* Mutation - createRefund
|
||||
*
|
||||
* Registers mutation for creating a refund on an order.
|
||||
*
|
||||
* @package WPGraphQL\WooCommerce\Mutation
|
||||
* @since TBD
|
||||
*/
|
||||
|
||||
namespace WPGraphQL\WooCommerce\Mutation;
|
||||
|
||||
use GraphQL\Error\UserError;
|
||||
use GraphQL\Type\Definition\ResolveInfo;
|
||||
use WPGraphQL\AppContext;
|
||||
use WPGraphQL\WooCommerce\Model\Order;
|
||||
|
||||
/**
|
||||
* Class Refund_Create
|
||||
*/
|
||||
class Refund_Create {
|
||||
/**
|
||||
* Registers mutation
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register_mutation() {
|
||||
register_graphql_mutation(
|
||||
'createRefund',
|
||||
[
|
||||
'inputFields' => self::get_input_fields(),
|
||||
'outputFields' => self::get_output_fields(),
|
||||
'mutateAndGetPayload' => self::mutate_and_get_payload(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the mutation input field configuration.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_input_fields() {
|
||||
return [
|
||||
'orderId' => [
|
||||
'type' => [ 'non_null' => 'Int' ],
|
||||
'description' => static function () {
|
||||
return __( 'The ID of the order to refund.', 'wp-graphql-woocommerce' );
|
||||
},
|
||||
],
|
||||
'amount' => [
|
||||
'type' => [ 'non_null' => 'String' ],
|
||||
'description' => static function () {
|
||||
return __( 'Refund amount.', 'wp-graphql-woocommerce' );
|
||||
},
|
||||
],
|
||||
'reason' => [
|
||||
'type' => 'String',
|
||||
'description' => static function () {
|
||||
return __( 'Reason for refund.', 'wp-graphql-woocommerce' );
|
||||
},
|
||||
],
|
||||
'refundPayment' => [
|
||||
'type' => 'Boolean',
|
||||
'description' => static function () {
|
||||
return __( 'When true, the payment gateway API is used to generate the refund.', 'wp-graphql-woocommerce' );
|
||||
},
|
||||
],
|
||||
'restockItems' => [
|
||||
'type' => 'Boolean',
|
||||
'description' => static function () {
|
||||
return __( 'When true, refunded items are restocked.', 'wp-graphql-woocommerce' );
|
||||
},
|
||||
],
|
||||
'metaData' => [
|
||||
'type' => [ 'list_of' => 'MetaDataInput' ],
|
||||
'description' => static function () {
|
||||
return __( 'Meta data.', 'wp-graphql-woocommerce' );
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the mutation output field configuration.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_output_fields() {
|
||||
return [
|
||||
'refund' => [
|
||||
'type' => 'Refund',
|
||||
'resolve' => static function ( $payload ) {
|
||||
return new Order( $payload['id'] );
|
||||
},
|
||||
],
|
||||
'order' => [
|
||||
'type' => 'Order',
|
||||
'resolve' => static function ( $payload ) {
|
||||
return new Order( $payload['order_id'] );
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the mutation data modification closure.
|
||||
*
|
||||
* @return callable
|
||||
*/
|
||||
public static function mutate_and_get_payload() {
|
||||
return static function ( $input, AppContext $context, ResolveInfo $info ) {
|
||||
$order_id = absint( $input['orderId'] );
|
||||
$order = \wc_get_order( $order_id );
|
||||
|
||||
if ( ! $order ) {
|
||||
throw new UserError( __( 'Invalid order ID.', 'wp-graphql-woocommerce' ) );
|
||||
}
|
||||
|
||||
if ( ! \wc_rest_check_post_permissions( 'shop_order', 'edit', $order_id ) ) {
|
||||
throw new UserError( __( 'You do not have permission to create refunds for this order.', 'wp-graphql-woocommerce' ) );
|
||||
}
|
||||
|
||||
$amount = floatval( $input['amount'] );
|
||||
if ( 0 >= $amount ) {
|
||||
throw new UserError( __( 'Refund amount must be greater than zero.', 'wp-graphql-woocommerce' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Action called before a refund is created.
|
||||
*
|
||||
* @param int $order_id Order ID.
|
||||
* @param array $input Input data.
|
||||
* @param \WPGraphQL\AppContext $context AppContext instance.
|
||||
* @param \GraphQL\Type\Definition\ResolveInfo $info ResolveInfo instance.
|
||||
*/
|
||||
do_action( 'graphql_woocommerce_before_refund_create', $order_id, $input, $context, $info );
|
||||
|
||||
$refund = \wc_create_refund(
|
||||
[
|
||||
'order_id' => $order_id,
|
||||
'amount' => $amount,
|
||||
'reason' => ! empty( $input['reason'] ) ? $input['reason'] : null,
|
||||
'refund_payment' => ! empty( $input['refundPayment'] ) ? $input['refundPayment'] : false,
|
||||
'restock_items' => ! empty( $input['restockItems'] ) ? $input['restockItems'] : false,
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $refund ) ) {
|
||||
throw new UserError( $refund->get_error_message() );
|
||||
}
|
||||
|
||||
if ( ! $refund ) {
|
||||
throw new UserError( __( 'Could not create refund, please try again.', 'wp-graphql-woocommerce' ) );
|
||||
}
|
||||
|
||||
// Set meta data.
|
||||
if ( ! empty( $input['metaData'] ) && is_array( $input['metaData'] ) ) {
|
||||
foreach ( $input['metaData'] as $meta ) {
|
||||
$refund->update_meta_data( $meta['key'], $meta['value'], isset( $meta['id'] ) ? $meta['id'] : '' );
|
||||
}
|
||||
$refund->save_meta_data();
|
||||
}
|
||||
|
||||
/**
|
||||
* Action called after a refund is created.
|
||||
*
|
||||
* @param \WC_Order_Refund $refund Refund object.
|
||||
* @param int $order_id Order ID.
|
||||
* @param array $input Input data.
|
||||
* @param \WPGraphQL\AppContext $context AppContext instance.
|
||||
* @param \GraphQL\Type\Definition\ResolveInfo $info ResolveInfo instance.
|
||||
*/
|
||||
do_action( 'graphql_woocommerce_after_refund_create', $refund, $order_id, $input, $context, $info );
|
||||
|
||||
return [
|
||||
'id' => $refund->get_id(),
|
||||
'order_id' => $order_id,
|
||||
];
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
/**
|
||||
* Mutation - deleteRefund
|
||||
*
|
||||
* Registers mutation for delete a refund on an order.
|
||||
*
|
||||
* @package WPGraphQL\WooCommerce\Mutation
|
||||
* @since TDB
|
||||
*/
|
||||
|
||||
namespace WPGraphQL\WooCommerce\Mutation;
|
||||
|
||||
use GraphQL\Error\UserError;
|
||||
use GraphQL\Type\Definition\ResolveInfo;
|
||||
use WC_Order_Factory;
|
||||
use WPGraphQL\AppContext;
|
||||
use WPGraphQL\WooCommerce\Data\Mutation\Order_Mutation;
|
||||
use WPGraphQL\WooCommerce\Model\Order;
|
||||
|
||||
/**
|
||||
* Class Refund_Delete
|
||||
*/
|
||||
class Refund_Delete {
|
||||
/**
|
||||
* Registers mutation
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register_mutation() {
|
||||
register_graphql_mutation(
|
||||
'deleteRefund',
|
||||
[
|
||||
'inputFields' => self::get_input_fields(),
|
||||
'outputFields' => self::get_output_fields(),
|
||||
'mutateAndGetPayload' => self::mutate_and_get_payload(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the mutation input field configuration
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_input_fields() {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the mutation output field configuration
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_output_fields() {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the mutation data modification closure.
|
||||
*
|
||||
* @return callable
|
||||
*/
|
||||
public static function mutate_and_get_payload() {
|
||||
return static function ( $input, AppContext $context, ResolveInfo $info ) {
|
||||
return [ 'id' => 0 ];
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
/**
|
||||
* Mutation - deleteRefund
|
||||
*
|
||||
* Registers mutation for deleting a refund on an order.
|
||||
*
|
||||
* @package WPGraphQL\WooCommerce\Mutation
|
||||
* @since TBD
|
||||
*/
|
||||
|
||||
namespace WPGraphQL\WooCommerce\Mutation;
|
||||
|
||||
use GraphQL\Error\UserError;
|
||||
use GraphQL\Type\Definition\ResolveInfo;
|
||||
use WPGraphQL\AppContext;
|
||||
use WPGraphQL\WooCommerce\Model\Order;
|
||||
|
||||
/**
|
||||
* Class Refund_Delete
|
||||
*/
|
||||
class Refund_Delete {
|
||||
/**
|
||||
* Registers mutation
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register_mutation() {
|
||||
register_graphql_mutation(
|
||||
'deleteRefund',
|
||||
[
|
||||
'inputFields' => self::get_input_fields(),
|
||||
'outputFields' => self::get_output_fields(),
|
||||
'mutateAndGetPayload' => self::mutate_and_get_payload(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the mutation input field configuration.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_input_fields() {
|
||||
return [
|
||||
'id' => [
|
||||
'type' => [ 'non_null' => 'ID' ],
|
||||
'description' => static function () {
|
||||
return __( 'The ID of the refund to delete.', 'wp-graphql-woocommerce' );
|
||||
},
|
||||
],
|
||||
'force' => [
|
||||
'type' => 'Boolean',
|
||||
'description' => static function () {
|
||||
return __( 'Force delete the refund. Defaults to true.', 'wp-graphql-woocommerce' );
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the mutation output field configuration.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_output_fields() {
|
||||
return [
|
||||
'refund' => [
|
||||
'type' => 'Refund',
|
||||
'resolve' => static function ( $payload ) {
|
||||
return ! empty( $payload['refund'] ) ? $payload['refund'] : null;
|
||||
},
|
||||
],
|
||||
'order' => [
|
||||
'type' => 'Order',
|
||||
'resolve' => static function ( $payload ) {
|
||||
return ! empty( $payload['order_id'] ) ? new Order( $payload['order_id'] ) : null;
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the mutation data modification closure.
|
||||
*
|
||||
* @return callable
|
||||
*/
|
||||
public static function mutate_and_get_payload() {
|
||||
return static function ( $input, AppContext $context, ResolveInfo $info ) {
|
||||
$refund_id = \WPGraphQL\Utils\Utils::get_database_id_from_id( $input['id'] );
|
||||
|
||||
if ( empty( $refund_id ) ) {
|
||||
throw new UserError( __( 'Invalid refund ID.', 'wp-graphql-woocommerce' ) );
|
||||
}
|
||||
|
||||
/** @var \WC_Order_Refund|false $refund */
|
||||
$refund = \wc_get_order( $refund_id );
|
||||
if ( ! $refund || 'shop_order_refund' !== $refund->get_type() ) {
|
||||
throw new UserError( __( 'Invalid refund ID.', 'wp-graphql-woocommerce' ) );
|
||||
}
|
||||
|
||||
$order_id = $refund->get_parent_id();
|
||||
if ( ! \wc_rest_check_post_permissions( 'shop_order', 'delete', $order_id ) ) {
|
||||
throw new UserError( __( 'You do not have permission to delete this refund.', 'wp-graphql-woocommerce' ) );
|
||||
}
|
||||
|
||||
// Capture refund data before deletion for the response.
|
||||
$refund_model = new Order( $refund_id );
|
||||
|
||||
/**
|
||||
* Action called before a refund is deleted.
|
||||
*
|
||||
* @param int $refund_id Refund ID.
|
||||
* @param int $order_id Order ID.
|
||||
* @param array $input Input data.
|
||||
* @param \WPGraphQL\AppContext $context AppContext instance.
|
||||
* @param \GraphQL\Type\Definition\ResolveInfo $info ResolveInfo instance.
|
||||
*/
|
||||
do_action( 'graphql_woocommerce_before_refund_delete', $refund_id, $order_id, $input, $context, $info );
|
||||
|
||||
$force = isset( $input['force'] ) ? $input['force'] : true;
|
||||
$result = $refund->delete( $force );
|
||||
|
||||
if ( ! $result ) {
|
||||
throw new UserError( __( 'Could not delete refund.', 'wp-graphql-woocommerce' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Action called after a refund is deleted.
|
||||
*
|
||||
* @param int $refund_id Refund ID.
|
||||
* @param int $order_id Order ID.
|
||||
* @param array $input Input data.
|
||||
* @param \WPGraphQL\AppContext $context AppContext instance.
|
||||
* @param \GraphQL\Type\Definition\ResolveInfo $info ResolveInfo instance.
|
||||
*/
|
||||
do_action( 'graphql_woocommerce_after_refund_delete', $refund_id, $order_id, $input, $context, $info );
|
||||
|
||||
return [
|
||||
'refund' => $refund_model,
|
||||
'order_id' => $order_id,
|
||||
];
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
class RefundMutationsTest extends \Tests\WPGraphQL\WooCommerce\TestCase\WooGraphQLTestCase {
|
||||
private $order_id;
|
||||
|
||||
public function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
update_option( 'woocommerce_prices_include_tax', 'no' );
|
||||
update_option( 'woocommerce_calc_taxes', 'yes' );
|
||||
|
||||
// Create a completed order as shop manager.
|
||||
$this->loginAsShopManager();
|
||||
|
||||
$product_id = $this->factory->product->createSimple( [ 'regular_price' => '100' ] );
|
||||
|
||||
$order = new \WC_Order();
|
||||
$order->set_status( 'completed' );
|
||||
$order->set_customer_id( 0 );
|
||||
$order->set_payment_method( 'bacs' );
|
||||
$order->add_product( wc_get_product( $product_id ), 2 );
|
||||
$order->set_total( 200 );
|
||||
$order->save();
|
||||
|
||||
$this->order_id = $order->get_id();
|
||||
}
|
||||
|
||||
public function testCreateRefundMutation() {
|
||||
$query = '
|
||||
mutation createRefund( $input: CreateRefundInput! ) {
|
||||
createRefund( input: $input ) {
|
||||
refund {
|
||||
databaseId
|
||||
amount
|
||||
reason
|
||||
}
|
||||
order {
|
||||
databaseId
|
||||
total
|
||||
}
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
// Assertion One: Customer cannot create refund.
|
||||
$this->loginAsCustomer();
|
||||
$response = $this->graphql(
|
||||
[
|
||||
'query' => $query,
|
||||
'variables' => [
|
||||
'input' => [
|
||||
'orderId' => $this->order_id,
|
||||
'amount' => '50',
|
||||
'reason' => 'Test refund',
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
$this->assertQueryError( $response );
|
||||
|
||||
// Assertion Two: Shop manager can create refund.
|
||||
$this->loginAsShopManager();
|
||||
$response = $this->graphql(
|
||||
[
|
||||
'query' => $query,
|
||||
'variables' => [
|
||||
'input' => [
|
||||
'orderId' => $this->order_id,
|
||||
'amount' => '50',
|
||||
'reason' => 'Test refund',
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$expected = [
|
||||
$this->expectedField( 'createRefund.refund.databaseId', self::NOT_NULL ),
|
||||
$this->expectedField( 'createRefund.refund.amount', 50.0 ),
|
||||
$this->expectedField( 'createRefund.refund.reason', 'Test refund' ),
|
||||
$this->expectedField( 'createRefund.order.databaseId', $this->order_id ),
|
||||
];
|
||||
|
||||
$this->assertQuerySuccessful( $response, $expected );
|
||||
|
||||
// Assertion Three: Invalid amount.
|
||||
$response = $this->graphql(
|
||||
[
|
||||
'query' => $query,
|
||||
'variables' => [
|
||||
'input' => [
|
||||
'orderId' => $this->order_id,
|
||||
'amount' => '0',
|
||||
'reason' => 'Zero refund',
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
$this->assertQueryError( $response );
|
||||
}
|
||||
|
||||
public function testDeleteRefundMutation() {
|
||||
$this->loginAsShopManager();
|
||||
|
||||
// Create a refund first.
|
||||
$refund = \wc_create_refund(
|
||||
[
|
||||
'order_id' => $this->order_id,
|
||||
'amount' => '25',
|
||||
'reason' => 'Refund to delete',
|
||||
]
|
||||
);
|
||||
$this->assertNotWPError( $refund );
|
||||
$refund_id = $refund->get_id();
|
||||
|
||||
$query = '
|
||||
mutation deleteRefund( $input: DeleteRefundInput! ) {
|
||||
deleteRefund( input: $input ) {
|
||||
refund {
|
||||
databaseId
|
||||
amount
|
||||
}
|
||||
order {
|
||||
databaseId
|
||||
}
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
// Assertion One: Customer cannot delete refund.
|
||||
$this->loginAsCustomer();
|
||||
$response = $this->graphql(
|
||||
[
|
||||
'query' => $query,
|
||||
'variables' => [
|
||||
'input' => [
|
||||
'id' => $this->toRelayId( 'order', $refund_id ),
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
$this->assertQueryError( $response );
|
||||
|
||||
// Assertion Two: Shop manager can delete refund.
|
||||
$this->loginAsShopManager();
|
||||
$response = $this->graphql(
|
||||
[
|
||||
'query' => $query,
|
||||
'variables' => [
|
||||
'input' => [
|
||||
'id' => $this->toRelayId( 'order', $refund_id ),
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$expected = [
|
||||
$this->expectedField( 'deleteRefund.refund.databaseId', $refund_id ),
|
||||
$this->expectedField( 'deleteRefund.refund.amount', 25.0 ),
|
||||
$this->expectedField( 'deleteRefund.order.databaseId', $this->order_id ),
|
||||
];
|
||||
|
||||
$this->assertQuerySuccessful( $response, $expected );
|
||||
|
||||
// Verify refund is actually deleted.
|
||||
$deleted_refund = \wc_get_order( $refund_id );
|
||||
$this->assertFalse( $deleted_refund );
|
||||
}
|
||||
|
||||
public function testDeleteRefundWithInvalidIdFails() {
|
||||
$this->loginAsShopManager();
|
||||
|
||||
$query = '
|
||||
mutation deleteRefund( $input: DeleteRefundInput! ) {
|
||||
deleteRefund( input: $input ) {
|
||||
refund { databaseId }
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
// Non-existent ID.
|
||||
$response = $this->graphql(
|
||||
[
|
||||
'query' => $query,
|
||||
'variables' => [
|
||||
'input' => [
|
||||
'id' => $this->toRelayId( 'order', 999999 ),
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
$this->assertQueryError(
|
||||
$response,
|
||||
[ $this->expectedErrorMessage( 'Invalid refund ID', self::MESSAGE_CONTAINS ) ]
|
||||
);
|
||||
}
|
||||
|
||||
public function testDeleteRefundWithOrderIdFails() {
|
||||
$this->loginAsShopManager();
|
||||
|
||||
$query = '
|
||||
mutation deleteRefund( $input: DeleteRefundInput! ) {
|
||||
deleteRefund( input: $input ) {
|
||||
refund { databaseId }
|
||||
}
|
||||
}
|
||||
';
|
||||
|
||||
// Pass an order ID instead of a refund ID.
|
||||
$response = $this->graphql(
|
||||
[
|
||||
'query' => $query,
|
||||
'variables' => [
|
||||
'input' => [
|
||||
'id' => $this->toRelayId( 'order', $this->order_id ),
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
$this->assertQueryError(
|
||||
$response,
|
||||
[ $this->expectedErrorMessage( 'Invalid refund ID', self::MESSAGE_CONTAINS ) ]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user