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 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:

  1. A sidebar widget — hooks fluent_support/customer_extra_widgets to list the customer’s EDD orders, each with a Refund and cancel button.
  2. A REST endpointmystore-edd/v1/refund, locked to manage_options, that runs the actual refund and cancel server-side.
  3. An inline click handler — a tiny window.myRefund() function that POSTs the order ID to the endpoint and reloads the ticket.
  4. 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

  1. Confirm Fluent Support and EDD 3.x are active (plus EDD Recurring if you sell subscriptions).
  2. Paste the snippet into a code-snippets plugin set to run everywhere, or save it as wp-content/mu-plugins/fs-refund.php.
  3. Rename the mystore-edd namespace and the MyStore_FS_Refund class to your own prefix.
  4. Open a ticket from a customer who has an order. The Refund (EDD) widget appears in the sidebar, one button per order.
  5. 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.

Stop Managing Servers!
Start Managing Clients.

Your clients deserve servers that never flinch.
You deserve to never think about servers again.

Whether you need a bulletproof setup built from scratch or someone to take over what you already have  I handle the infrastructure so your agency can focus on what it bills for.

No downtime calls. No 3am panics. No excuses.

Explore Further