---
title: "Build a One-Click EDD Refund Button Inside Fluent Support (Full Code)"
url: https://adityaarsharma.com/fluent-support-one-click-refund-button/
date: 2026-06-24
modified: 2026-06-24
author: "Aditya R Sharma"
description: "The complete snippet for a one-click EDD refund-and-cancel button in the Fluent Support ticket sidebar — plus the five Vue-SPA gotchas that make it actually work."
categories:
  - "Automation"
  - "WordPress"
word_count: 1436
---

# Build a One-Click EDD Refund Button Inside Fluent Support (Full Code)

**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](https://wordpress.org/plugins/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_widgets` to list the customer's [EDD](https://wordpress.org/plugins/easy-digital-downloads/) orders, each with a *Refund and cancel* button.
- **A REST endpoint** — `mystore-edd/v1/refund`, locked to `manage_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>#%1$d - %2$s%3$s'
. '<button onclick="window.myRefund(this);return false;" '
. '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-edd` namespace and the `MyStore_FS_Refund` class 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.