Returns & Refunds

Building Return and Refund Abilities Safely

⏱ 18 min

Treat this lesson with the weight it deserves: a refund moves real money out of a real payment gateway, and once that call succeeds, it is not something you casually undo. Of everything built so far in this course, this is the first ability that can directly cost your business money if it runs wrong, whether that’s an agent misreading an amount, a prompt injection buried in an order note, or a person confirming something they misunderstood. Nothing here treats that lightly. The refund ability in this lesson is confirmation-gated by default with no bypass, and refunds above a threshold you control require a second, distinct human to sign off, not just the same person twice.

What you'll learn in this lesson
The real wc_create_refund() function
Its actual arguments, refund_payment versus a record-only refund, and its return type.
A confirmation-gated refund ability with a hard threshold
Built on Course 3's propose/confirm pattern, with no configuration path that skips it.
A second-approver rule above a configurable amount
The proposing user and the confirming user must differ once the refund crosses your threshold.
Handling exchanges with wc_create_order()
Creating a real replacement order rather than pretending a refund and an exchange are the same thing.
Prerequisites

Lesson 2’s propose/confirm pattern for orders. A WooCommerce install with at least one gateway configured that supports programmatic refunds if you intend to test refund_payment => true against anything beyond a sandbox account. Never test this against a live payment gateway with real customer money.

This ability can move real money out of a real gateway

When refund_payment is true, wc_create_refund() calls the active payment gateway’s own refund API. That is a genuine, gateway-side financial transaction, not a WordPress database update. Test everything in this lesson against a gateway’s sandbox or test mode first, and never against a live account, until you’ve verified every path, including the failure paths, behaves exactly as you expect.

Step 1: What wc_create_refund() actually accepts and returns

wc_create_refund( $args ) is a real, current WooCommerce function. Its accepted $args keys, with their defaults, are:

wc_create_refund() arguments
KeyDefaultWhat it does
amount0Total refund amount. Can override the sum of line-item refunds.
reasonnullText reason, stored on the refund and visible in order notes.
order_id0The order being refunded.
line_itemsarray()Per-item refund amounts, for partial, line-level refunds.
refund_paymentfalseIf true, calls the active payment gateway’s refund API, real money movement. If false, records the refund without contacting the gateway.
restock_itemsfalseIf true, returns refunded line-item quantities to stock.

It returns a WC_Order_Refund object on success, or a WP_Error on failure. Always check for WP_Error before treating a call as successful.

Two different argument names for the same idea, REST API versus PHP function

WooCommerce’s REST API v3 order-refunds endpoint uses api_refund and api_restock (both default true there). The PHP function wc_create_refund() you’re calling directly in this lesson uses refund_payment and restock_items (both default false here). These are two different interfaces to related but distinct behavior, don’t copy argument names from REST API documentation into a direct PHP call, or vice versa, they will silently be ignored rather than throwing an error.

Step 2: The propose step, computing the amount and checking the threshold

// File: my-plugin.php
define( 'MY_PLUGIN_REFUND_SECOND_APPROVER_THRESHOLD', 100.00 );

wp_register_ability( 'my-plugin/propose-refund', array(
	'label'       => __( 'Propose a refund', 'my-plugin' ),
	'description' => __( 'Describes a refund for a specific order and amount, and returns a confirmation token. Does not refund anything. Refunds at or above the store threshold require a second, different approving user.', 'my-plugin' ),
	'input_schema' => array(
		'type'       => 'object',
		'properties' => array(
			'order_id'       => array( 'type' => 'integer' ),
			'amount'         => array( 'type' => 'number' ),
			'reason'         => array( 'type' => 'string' ),
			'refund_payment' => array( 'type' => 'boolean', 'default' => true ),
			'restock_items'  => array( 'type' => 'boolean', 'default' => true ),
		),
		'required' => array( 'order_id', 'amount', 'reason' ),
	),
	'permission_callback' => function () {
		return current_user_can( 'manage_woocommerce' );
	},
	'execute_callback' => function ( $input ) {
		$order = wc_get_order( $input['order_id'] );
		if ( ! $order ) {
			return new WP_Error( 'not_found', 'No order with that ID exists.' );
		}
		if ( (float) $input['amount'] > (float) $order->get_total() ) {
			return new WP_Error( 'amount_too_large', 'Refund amount exceeds the order total.' );
		}

		$needs_second_approver = (float) $input['amount'] >= MY_PLUGIN_REFUND_SECOND_APPROVER_THRESHOLD;
		$token = wp_generate_password( 24, false );

		set_transient(
			'agent_refund_token_' . $token,
			array(
				'order_id'              => $order->get_id(),
				'amount'                => (float) $input['amount'],
				'reason'                => sanitize_text_field( $input['reason'] ),
				'refund_payment'        => (bool) $input['refund_payment'],
				'restock_items'         => (bool) $input['restock_items'],
				'proposing_user_id'     => get_current_user_id(),
				'needs_second_approver' => $needs_second_approver,
			),
			10 * MINUTE_IN_SECONDS
		);

		return array(
			'order_id'              => $order->get_id(),
			'amount'                => (float) $input['amount'],
			'effect'                => $needs_second_approver
				? 'This refund is at or above the store\'s review threshold and requires sign-off from a different manager than the one proposing it.'
				: 'This will refund the order and, if refund_payment is true, contact the payment gateway for a real transaction.',
			'requires_second_approver' => $needs_second_approver,
			'confirmation_token'    => $token,
		);
	},
	'meta' => array( 'mcp' => array( 'public' => false ) ),
) );

Step 3: The confirm step, enforcing the second-approver rule with no bypass

// File: my-plugin.php
wp_register_ability( 'my-plugin/confirm-refund', array(
	'label'       => __( 'Confirm and execute a proposed refund', 'my-plugin' ),
	'description' => __( 'Executes a refund previously described by my-plugin/propose-refund. Refunds above the store threshold require a confirming user different from the proposing one.', 'my-plugin' ),
	'input_schema' => array(
		'type'       => 'object',
		'properties' => array( 'confirmation_token' => array( 'type' => 'string' ) ),
		'required'   => array( 'confirmation_token' ),
	),
	'permission_callback' => function () {
		return current_user_can( 'manage_woocommerce' );
	},
	'execute_callback' => function ( $input ) {
		$key     = 'agent_refund_token_' . $input['confirmation_token'];
		$pending = get_transient( $key );

		if ( ! $pending ) {
			return new WP_Error( 'invalid_or_expired_token', 'This confirmation has expired or was never issued.' );
		}

		if ( $pending['needs_second_approver']
			&& $pending['proposing_user_id'] === get_current_user_id() ) {
			return new WP_Error(
				'second_approver_required',
				'This refund amount requires sign-off from a manager other than the one who proposed it. Have a different manager confirm it.'
			);
		}

		delete_transient( $key ); // one-time use, invalidated before the refund runs

		$refund = wc_create_refund( array(
			'order_id'       => $pending['order_id'],
			'amount'         => $pending['amount'],
			'reason'         => $pending['reason'],
			'refund_payment' => $pending['refund_payment'],
			'restock_items'  => $pending['restock_items'],
		) );

		if ( is_wp_error( $refund ) ) {
			return $refund;
		}

		$order = wc_get_order( $pending['order_id'] );
		$order->add_order_note( sprintf(
			'Agent-initiated refund of %s confirmed by user #%d. Reason: %s',
			wc_price( $pending['amount'] ),
			get_current_user_id(),
			$pending['reason']
		) );

		return array( 'refunded' => true, 'refund_id' => $refund->get_id(), 'amount' => $pending['amount'] );
	},
	'meta' => array( 'mcp' => array( 'public' => false ) ),
) );

Notice that MY_PLUGIN_REFUND_SECOND_APPROVER_THRESHOLD is a hard-coded constant, not a site option an agent, or a compromised admin session, could quietly lower. If you want it configurable, expose it through a filter that only runs in PHP you control, never through any input an ability itself accepts.

The second-approver check compares user IDs, not roles

Two different WordPress accounts that both happen to have the manage_woocommerce capability still satisfy this check technically, even if the same person is logged into both. This is a real limitation of an automated check: it enforces that two distinct accounts were involved, not that two distinct humans made an independent judgment. Pair this with your store’s actual account-hygiene policy (one person, one account) for the rule to mean what it’s meant to mean.

Step 4: Exchanges, a real replacement order with wc_create_order()

Not every return is a refund. Sometimes the right outcome is a replacement item, which means creating a real new order rather than pretending a refund and an exchange are the same operation. wc_create_order( $args ) accepts status, customer_id, customer_note, parent, created_via, and cart_hash (all default null), plus order_id (default 0), and returns a WC_Order object on success or a WP_Error on failure:

// File: my-plugin.php
function my_plugin_create_exchange_order( $original_order_id, $replacement_product_id, $qty = 1 ) {
	$original = wc_get_order( $original_order_id );
	if ( ! $original ) {
		return new WP_Error( 'not_found', 'Original order not found.' );
	}

	$product = wc_get_product( $replacement_product_id );
	if ( ! $product ) {
		return new WP_Error( 'not_found', 'Replacement product not found.' );
	}

	$new_order = wc_create_order( array(
		'customer_id'   => $original->get_customer_id(),
		'customer_note' => 'Exchange replacement for order #' . $original->get_id(),
		'created_via'   => 'agent-exchange',
	) );

	if ( is_wp_error( $new_order ) ) {
		return $new_order;
	}

	$new_order->add_product( $product, $qty );
	$new_order->calculate_totals();
	$new_order->update_status( 'pending', 'Created by agent-assisted exchange flow, awaiting manual review before fulfillment.' );

	$original->add_order_note( sprintf( 'Exchange replacement order #%d created.', $new_order->get_id() ) );

	return array( 'new_order_id' => $new_order->get_id(), 'status' => $new_order->get_status() );
}

This function only creates a pending order, it does not take payment, ship anything, or refund the original order automatically. Wire it behind the same propose/confirm pattern as the refund ability above if you expose it to an agent, an exchange still commits real inventory and, usually, a decision not to charge the customer again for the replacement item.

Test it

Against a sandbox gateway account only:

wp-env run cli wp eval '
$propose = wp_get_ability( "my-plugin/propose-refund" )->execute( array( "order_id" => 101, "amount" => 25.00, "reason" => "Damaged on arrival" ) );
print_r( $propose );
'

Confirm with the token from the same user for a small amount, and separately verify a refund at or above your threshold is rejected when the same user proposes and confirms it, and only succeeds when a second, different manager account confirms it.

Forgetting to check is_wp_error() on the refund object

wc_create_refund() returns WP_Error, not a thrown exception, on failure (an invalid order, an amount exceeding the order total, a gateway rejecting the refund call). Code that assumes success and calls $refund->get_id() without checking is_wp_error() first will fatal on exactly the failures you most need to see reported clearly.

Recap

A refund is real money leaving a real gateway, and this lesson’s ability treats it that way at every step: wc_create_refund() called only after a propose/confirm round trip, a hard-coded threshold above which the confirming user must differ from the proposing one, and no configuration path that skips either check. Exchanges get their own path through wc_create_order(), building a genuine replacement order rather than conflating “send the money back” with “send a different item instead.”

Resources & further reading

← Permission-Gated Action Abilities for Orders Processing Payments Through an Agent: Where to Draw the Line →