The problem: a customer opens a ticket asking for a refund. Without this, your agent leaves the ticket, opens the store dashboard, finds the order, refunds it, then cancels the subscription on a separate screen. I wanted one button in the ticket sidebar that does all of it — refund through the original gateway, cancel the subscription, reload. Here is the complete snippet, and the five Fluent Support quirks that stand between you and it. Last updated: June 2026.
How the snippet is wired
It’s one small class with four moving parts:
- A sidebar widget — hooks
fluent_support/customer_extra_widgetsto list the customer’s EDD orders, each with a Refund and cancel button. - A REST endpoint —
mystore-edd/v1/refund, locked tomanage_options, that runs the actual refund and cancel server-side. - An inline click handler — a tiny
window.myRefund()function that POSTs the order ID to the endpoint and reloads the ticket. - Five workarounds — because the agent screen is a Vue single-page app on a custom front-end URL, the normal WordPress wiring misbehaves. Each one is called out in the walkthrough below.
Prerequisites: Fluent Support and Easy Digital Downloads 3.x active. The subscription-cancel step also needs EDD Recurring.
The complete snippet
<?php
/**
* One-click EDD "Refund and cancel" button in the Fluent Support ticket sidebar.
* Paste into a code-snippets plugin (run site-wide) or an mu-plugin.
* Needs: Fluent Support + Easy Digital Downloads 3.x (EDD Recurring for the cancel).
*/
if ( ! defined( 'ABSPATH' ) ) { exit; }
class MyStore_FS_Refund {
const NS = 'mystore-edd/v1';
public function __construct() {
add_filter( 'fluent_support/customer_extra_widgets', array( $this, 'widget' ), 999, 2 );
add_action( 'rest_api_init', array( $this, 'routes' ) );
add_action( 'wp_footer', array( $this, 'print_script' ) ); // Fluent Support portal
add_action( 'admin_footer', array( $this, 'print_script' ) ); // wp-admin fallback
}
/** Inject a Refund widget that lists the customer's EDD orders. */
public function widget( $widgets, $customer ) {
$email = is_object( $customer ) && isset( $customer->email ) ? $customer->email : '';
if ( ! $email || ! function_exists( 'edd_get_orders' ) ) {
return $widgets;
}
$orders = edd_get_orders( array(
'email' => $email,
'type' => 'sale', // GOTCHA 5: skip type='refund' negative rows
'status' => 'complete',
'number' => 20,
) );
$rows = '';
foreach ( $orders as $order ) {
$sub_id = $this->subscription_id( $order->id );
$nonce = wp_create_nonce( 'mystore_refund_' . $order->id );
$label = function_exists( 'edd_currency_filter' )
? edd_currency_filter( edd_format_amount( $order->total ) )
: $order->total;
$rows .= sprintf(
'<div style="padding:6px 0;border-bottom:1px solid #eee;">#%1$d - %2$s%3$s'
. '<button onclick="window.myRefund(this);return false;" data-order="%1$d" data-sub="%4$d" data-nonce="%5$s" '
. 'style="display:block;margin-top:4px;padding:6px 10px;background:#b00020;color:#fff;border:0;border-radius:4px;cursor:pointer;">Refund and cancel</button></div>',
(int) $order->id, esc_html( $label ),
$sub_id ? ' (sub #' . (int) $sub_id . ')' : '',
(int) $sub_id, esc_attr( $nonce )
);
}
$widgets['mystore_refund'] = array(
'header' => 'Refund (EDD)',
'body_html' => $rows ? $rows : 'No refundable orders.',
);
return $widgets;
}
/** Find an active subscription for an order (needs EDD Recurring). */
private function subscription_id( $order_id ) {
global $wpdb;
$table = $wpdb->prefix . 'edd_subscriptions';
if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) {
return 0; // EDD Recurring not installed
}
return (int) $wpdb->get_var( $wpdb->prepare(
"SELECT id FROM {$table} WHERE parent_payment_id = %d AND status != 'cancelled' LIMIT 1",
$order_id
) );
}
/** Register the privileged refund endpoint. */
public function routes() {
register_rest_route( self::NS, '/refund', array(
'methods' => 'POST',
'permission_callback' => function () { return current_user_can( 'manage_options' ); },
'callback' => array( $this, 'do_refund' ),
) );
}
/** Refund through the original gateway, then cancel the subscription. */
public function do_refund( $req ) {
$order_id = absint( $req->get_param( 'order' ) );
$sub_id = absint( $req->get_param( 'sub' ) );
$nonce = sanitize_text_field( (string) $req->get_param( 'nonce' ) );
if ( ! $order_id || ! wp_verify_nonce( $nonce, 'mystore_refund_' . $order_id ) ) {
return new WP_Error( 'bad_nonce', 'Invalid request', array( 'status' => 403 ) );
}
if ( function_exists( 'edd_refund_order' ) ) {
edd_refund_order( $order_id ); // EDD 3.x routes to Stripe / PayPal automatically
}
if ( $sub_id && class_exists( 'EDD_Subscription' ) ) {
$sub = new EDD_Subscription( $sub_id );
if ( $sub && $sub->id ) { $sub->cancel(); }
}
return rest_ensure_response( array( 'ok' => true ) );
}
/** Print the click handler once, on both the portal and wp-admin. */
public function print_script() {
static $done = false;
if ( $done || ! is_user_logged_in() ) { return; } // GOTCHA 1 + 2
$done = true;
$ep = esc_js( rest_url( self::NS . '/refund' ) );
$rest = esc_js( wp_create_nonce( 'wp_rest' ) );
echo "<script>
window.myRefund = function (btn) {
if ( ! confirm('Refund and cancel this order? This cannot be undone.') ) return;
btn.disabled = true; btn.textContent = 'Processing...';
fetch('{$ep}', { method:'POST',
headers:{ 'Content-Type':'application/json', 'X-WP-Nonce':'{$rest}' },
body: JSON.stringify({ order:btn.dataset.order, sub:btn.dataset.sub, nonce:btn.dataset.nonce }) })
.then(function(r){ return r.json(); })
.then(function(){ location.reload(); })
.catch(function(){ btn.disabled = false; btn.textContent = 'Retry'; });
};
new MutationObserver(function(){
document.querySelectorAll('.fs_collapsible_widget').forEach(function(w){
if ( ! w.classList.contains('fs_widget_expanded') ) {
var h = w.querySelector('.fs_widget_header'); if (h) h.click();
}
});
}).observe(document.body, { childList:true, subtree:true });
</script>";
}
}
new MyStore_FS_Refund();
Walkthrough: the five things that fought me
The class is short. These five quirks are why it took longer than it should have.
1. admin_footer never fires on the portal
The Fluent Support agent screen renders on the front end (a custom slug like /helpdesk-backend/), so admin_footer never runs there and your script never prints. print_script() is hooked to both wp_footer and admin_footer, with a static $done flag so it only outputs once when both fire.
2. Don’t gate on the page slug
My first version checked strpos( $page, 'fluent-support' ) — but the portal slug isn’t that, so nothing loaded. The fix is to drop the page check entirely and gate on is_user_logged_in(). Authorization isn’t weakened: the REST endpoint still enforces manage_options and a per-order nonce.
3. Vue eats your click handlers
A delegated document.addEventListener('click', ...) — even in the capture phase — doesn’t fire reliably inside the SPA; Vue’s listeners swallow it and re-render the button out from under your handler. The fix that survives re-renders is a native inline onclick="window.myRefund(this)" attribute. Vue cannot intercept native HTML onclick attributes.
4. wp_localize_script silently drops your data
Registering a script with a false src and attaching data via wp_localize_script didn’t reliably output on the portal — the localized object simply wasn’t there at runtime. So the endpoint URL and nonce are echoed straight into the inline script (the {$ep} / {$rest} interpolation). One less moving part, zero silent failures.
5. EDD’s type column hides a phantom refund row
List only sales and renewals. EDD 3.x stores each refund as its own row with type = 'refund' and a negative total, so an unfiltered query shows ghost −$9.00 “orders” in the sidebar. The 'type' => 'sale' argument on edd_get_orders() keeps those out.
Bonus: the widget loads collapsed
Fluent Support renders sidebar widgets collapsed. The small MutationObserver at the end of print_script() opens the EDD widget as soon as it appears, so agents don’t have to click twice.
Install it
- Confirm Fluent Support and EDD 3.x are active (plus EDD Recurring if you sell subscriptions).
- Paste the snippet into a code-snippets plugin set to run everywhere, or save it as
wp-content/mu-plugins/fs-refund.php. - Rename the
mystore-eddnamespace and theMyStore_FS_Refundclass to your own prefix. - Open a ticket from a customer who has an order. The Refund (EDD) widget appears in the sidebar, one button per order.
- Click Refund and cancel, confirm, and the ticket reloads with the order refunded and the subscription cancelled.
FAQ
Does this work with Stripe and PayPal?
Yes. On EDD 3.x, edd_refund_order() calls the original gateway’s refund API for you, so a Stripe order refunds through Stripe and a PayPal order through PayPal — no gateway-specific code in your snippet.
Will it cancel the subscription too?
Yes, when the order has one and EDD Recurring is active. The endpoint loads the linked EDD_Subscription and calls cancel(), so the customer isn’t billed again.
Is the button safe to expose in the agent portal?
The button only carries an order ID, the subscription ID, and a per-order nonce. All privileged work happens in the REST endpoint, which requires manage_options and verifies the nonce, so a logged-out or low-privilege user can’t trigger a refund.
Where do I paste it?
A code-snippets plugin with site-wide scope, or an mu-plugin. Don’t scope it to admin only — the agent portal is a front-end page, so an admin-only snippet never loads there.
The class is about 90 lines. The lines were never the hard part — the five Vue-SPA quirks were. Now refunds happen inside the ticket, and nobody touches the store dashboard.