From 2107c7e034738536d6fc5dff7d170dcb7a0a9fc1 Mon Sep 17 00:00:00 2001 From: Geoff Taylor Date: Tue, 31 Mar 2026 10:24:01 -0400 Subject: [PATCH] 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. --- includes/class-type-registry.php | 2 + includes/class-wp-graphql-woocommerce.php | 2 + includes/mutation/class-refund-create.php | 182 ++++++++++++++++++ includes/mutation/class-refund-delete-php | 68 +++++++ includes/mutation/class-refund-delete.php | 144 ++++++++++++++ tests/wpunit/RefundMutationsTest.php | 224 ++++++++++++++++++++++ 6 files changed, 622 insertions(+) create mode 100644 includes/mutation/class-refund-create.php create mode 100644 includes/mutation/class-refund-delete-php create mode 100644 includes/mutation/class-refund-delete.php create mode 100644 tests/wpunit/RefundMutationsTest.php diff --git a/includes/class-type-registry.php b/includes/class-type-registry.php index aa348567..d7c4e557 100644 --- a/includes/class-type-registry.php +++ b/includes/class-type-registry.php @@ -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(); diff --git a/includes/class-wp-graphql-woocommerce.php b/includes/class-wp-graphql-woocommerce.php index e35b7818..0eb017ab 100644 --- a/includes/class-wp-graphql-woocommerce.php +++ b/includes/class-wp-graphql-woocommerce.php @@ -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'; diff --git a/includes/mutation/class-refund-create.php b/includes/mutation/class-refund-create.php new file mode 100644 index 00000000..2f945ff0 --- /dev/null +++ b/includes/mutation/class-refund-create.php @@ -0,0 +1,182 @@ + 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, + ]; + }; + } +} diff --git a/includes/mutation/class-refund-delete-php b/includes/mutation/class-refund-delete-php new file mode 100644 index 00000000..fb691448 --- /dev/null +++ b/includes/mutation/class-refund-delete-php @@ -0,0 +1,68 @@ + 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 ]; + }; + } +} \ No newline at end of file diff --git a/includes/mutation/class-refund-delete.php b/includes/mutation/class-refund-delete.php new file mode 100644 index 00000000..fa9fa092 --- /dev/null +++ b/includes/mutation/class-refund-delete.php @@ -0,0 +1,144 @@ + 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, + ]; + }; + } +} diff --git a/tests/wpunit/RefundMutationsTest.php b/tests/wpunit/RefundMutationsTest.php new file mode 100644 index 00000000..94269680 --- /dev/null +++ b/tests/wpunit/RefundMutationsTest.php @@ -0,0 +1,224 @@ +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 ) ] + ); + } +}