Repair WooCommerce Subscriptions: switches, coupons, and data

Wrong coupon type on renewals

A customer signed up with a "first order only" coupon, and it should have stopped there. Instead every renewal since keeps taking the same discount off the top, month after month. WooCommerce Subscriptions is built to tell recurring coupons from one time coupons, but when a coupon of the wrong type ends up on a renewal anyway, nothing stops it from repeating forever. Here is why it slips through and a small script that finds every renewal carrying the wrong coupon type and strips it out.

Python and Node.js Runs on a schedule Safe by default (dry run)
A neon sign that says sale
Photo by Igor Omilaev on Unsplash
The short answer

WooCommerce Subscriptions only carries a coupon forward onto renewal orders when the coupon's discount type is recurring_percent, recurring_fixed_cart, or recurring_fixed_product. A normal one time coupon (percent, fixed_cart, fixed_product) should never appear on a renewal, but it can if it was applied by hand, added on an older version of the plugin, or built as the wrong type from the start. Run a small Python or Node.js script on a schedule that reads each subscription's renewal orders, checks the real discount type of every applied coupon, and removes any coupon that is not a recurring type, then recalculates the total. Full code, tests, and a dry run guard are below.

The problem in plain words

A coupon in WooCommerce has a discount type, set once when it is created. Types like percent and fixed_cart are meant for a single order. Types like recurring_percent and recurring_fixed_cart are meant to travel with a subscription and apply again on every renewal, for as long as the subscription lives.

WooCommerce Subscriptions checks this type when it builds each renewal order. It is supposed to copy forward only the recurring types and leave the one time types behind on the original order. Most of the time that check works exactly as designed. The trouble starts when a coupon ends up on a renewal despite being the wrong type, because at that point Subscriptions has no second check to catch it. It just keeps discounting.

First order WELCOME10 applied Coupon type: percent not a recurring type should stop here Renewal 1 coupon still applied Renewal 2, 3... still undercharged Every renewal since keeps taking the same discount off the top.
The coupon was only ever meant to touch the first order, but it keeps riding along on every renewal instead.

Why it happens

The WooCommerce Subscriptions documentation describes the recurring coupon types as the only ones designed to apply again on renewal. A few common ways a one time coupon ends up on a renewal anyway:

This is a known and reported class of bug in the WooCommerce Subscriptions coupon handling, where the fix depends on the coupon type being read correctly at renewal time. See the citations at the end for the exact references.

The key insight

The coupon's discount_type, not the coupon code, decides whether it belongs on a renewal. A script that trusts the code alone will miss the bug entirely. A repair has to look up the real type from the coupon itself and only keep the three recurring types on any renewal order.

The fix, as a flow

We do not touch the checkout or the coupon settings screen. We add a job that reads each active subscription's renewal orders, collects every coupon code applied to them, looks up each coupon's real discount type, and removes any coupon line whose type is not one of the three recurring types. Removing the line and recalculating the order total means the next charge reflects the true subscription price.

Scheduled job once a day List renewal orders per active subscription Look up real coupon discount_type Is it a recurring type? yes, skip no, fix it Remove coupon recalc total + note
The script only removes a coupon from a renewal after confirming, from the coupon itself, that its type was never meant to repeat.

Build it step by step

1

Get access to the store

This fix only needs a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders, subscriptions, and coupons. Create it under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
pip install requests

export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_SUBSCRIPTIONS="200"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install

export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_SUBSCRIPTIONS="200"
export DRY_RUN="true"   // start safe, change to false to write
2

List renewal orders for each active subscription

The WooCommerce Subscriptions REST API exposes a subscription's orders through /subscriptions/{id}/orders, filterable by type. We only care about renewal orders here, since those are the only ones that should ever carry a recurring coupon forward.

step2.py
import os, requests
from requests.auth import HTTPBasicAuth

WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])

def get_renewal_orders(subscription_id):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/orders",
        params={"type": "renewal"},
        auth=AUTH, timeout=30,
    )
    if r.status_code == 404:
        return []
    r.raise_for_status()
    return r.json()
step2.js
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");

async function woo(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function getRenewalOrders(subscriptionId) {
  const result = await woo(`/subscriptions/${subscriptionId}/orders?type=renewal`);
  return result || [];
}
3

Look up each coupon's real discount type

Never trust the coupon code alone. Query the coupon by code and read its discount_type field directly. This is the one field that tells you whether WooCommerce Subscriptions should have kept applying it.

step3.py
def get_coupon_types(codes):
    """Look up discount_type for a set of coupon codes. Returns {code_lower: type}."""
    types = {}
    for code in codes:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/coupons",
            params={"code": code},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        matches = r.json()
        if matches:
            types[code.lower()] = matches[0].get("discount_type")
    return types
step3.js
async function getCouponTypes(codes) {
  const types = {};
  for (const code of codes) {
    const matches = await woo(`/coupons?code=${encodeURIComponent(code)}`);
    if (matches && matches.length) {
      types[code.toLowerCase()] = matches[0].discount_type;
    }
  }
  return types;
}
4

Decide, with one pure function

Keep the decision in its own function that takes an order and the coupon type lookup and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. If the order is not a renewal, skip it. If it is cancelled, refunded, or failed, skip it. If none of its coupons are a wrong type, skip it. Otherwise, fix it.

decide.py
RECURRING_TYPES = {"recurring_percent", "recurring_fixed_cart", "recurring_fixed_product"}
RENEWAL_META_KEY = "_subscription_renewal"

def is_renewal_order(order):
    for meta in order.get("meta_data") or []:
        if meta.get("key") == RENEWAL_META_KEY:
            return True
    return False

def bad_coupons_on_order(order, coupon_types_by_code):
    bad = []
    for line in order.get("coupon_lines") or []:
        code = (line.get("code") or "").lower()
        discount_type = coupon_types_by_code.get(code)
        if discount_type is not None and discount_type not in RECURRING_TYPES:
            bad.append(line)
    return bad

def decide(order, coupon_types_by_code):
    if not is_renewal_order(order):
        return ("skip", "not a renewal order", [])
    if order.get("status") in ("cancelled", "refunded", "failed", "trash"):
        return ("skip", "order is not in a state worth editing", [])
    bad = bad_coupons_on_order(order, coupon_types_by_code)
    if not bad:
        return ("skip", "no non recurring coupon on this renewal", [])
    return ("fix", "a non recurring coupon is applied to a renewal", bad)
decide.js
const RECURRING_TYPES = new Set(["recurring_percent", "recurring_fixed_cart", "recurring_fixed_product"]);
const RENEWAL_META_KEY = "_subscription_renewal";

export function isRenewalOrder(order) {
  return (order.meta_data || []).some((meta) => meta.key === RENEWAL_META_KEY);
}

export function badCouponsOnOrder(order, couponTypesByCode) {
  const bad = [];
  for (const line of order.coupon_lines || []) {
    const code = (line.code || "").toLowerCase();
    const discountType = couponTypesByCode[code];
    if (discountType !== undefined && !RECURRING_TYPES.has(discountType)) {
      bad.push(line);
    }
  }
  return bad;
}

export function decide(order, couponTypesByCode) {
  if (!isRenewalOrder(order)) return ["skip", "not a renewal order", []];
  if (["cancelled", "refunded", "failed", "trash"].includes(order.status)) {
    return ["skip", "order is not in a state worth editing", []];
  }
  const bad = badCouponsOnOrder(order, couponTypesByCode);
  if (bad.length === 0) return ["skip", "no non recurring coupon on this renewal", []];
  return ["fix", "a non recurring coupon is applied to a renewal", bad];
}
5

Strip the coupon and recalculate

When the action is fix, delete the coupon line from the order through the REST API, which recalculates the order total for you. Then add an order note stating how much was restored, in cents, so the shop manager can see the order was repaired and why.

apply.py
def money_to_minor(amount):
    return round(float(amount) * 100)

def discount_minor_of(lines):
    total = 0
    for line in lines:
        total += money_to_minor(line.get("discount") or "0")
    return total

def strip_coupon(order_id, bad_lines):
    for line in bad_lines:
        requests.delete(
            f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/coupons/{line['id']}",
            auth=AUTH, timeout=30,
        ).raise_for_status()
    added_back = discount_minor_of(bad_lines)
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
        json={"note": (
            "Removed a non recurring coupon that had been applied to this renewal. "
            f"Restored {added_back / 100:.2f} to the order total."
        )},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
export function moneyToMinor(amount) {
  return Math.round(parseFloat(amount) * 100);
}

export function discountMinorOf(lines) {
  return lines.reduce((total, line) => total + moneyToMinor(line.discount || "0"), 0);
}

async function stripCoupon(orderId, badLines) {
  for (const line of badLines) {
    await woo(`/orders/${orderId}/coupons/${line.id}`, { method: "DELETE" });
  }
  const addedBack = discountMinorOf(badLines);
  await woo(`/orders/${orderId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: "Removed a non recurring coupon that had been applied to this renewal. " +
            `Restored ${(addedBack / 100).toFixed(2)} to the order total.`,
    }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would do. Read the output, check the coupon codes match what you expect, then switch it off to let it write. Run it once a day, since renewal coupons do not change minute to minute.

Run it safe

Always start with DRY_RUN=true. Stripping a coupon changes what a customer is charged next, so you want to see the exact list of renewal orders and coupon codes before it acts. Once the report looks right for a run or two, turn it off.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only ever removes a coupon whose type it confirmed is not a recurring type.

View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.

strip_bad_renewal_coupons.py
"""Detect and strip a non recurring coupon that got applied to a subscription renewal.

WooCommerce Subscriptions only carries a coupon onto renewal orders when the coupon
is one of the recurring discount types (recurring_percent, recurring_fixed_cart,
recurring_fixed_product). A normal one time coupon (percent, fixed_cart,
fixed_product) should only ever discount the first, parent order. If one is found
sitting on a renewal, it is almost always a leftover from a manual coupon add, an
older Subscriptions version, or a support agent applying a "first order only" code
by hand. Left alone it quietly discounts every future renewal forever.

This walks the renewal orders on each subscription, finds coupons whose discount
type is not in the recurring set, removes the coupon line from the order, and
recalculates the order totals so the renewal charges the correct amount next time.
Safe by default. Run on a schedule or by hand against one subscription.
"""
import os
import logging
import requests
from requests.auth import HTTPBasicAuth

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("strip_bad_renewal_coupons")

WOO_URL = os.environ.get("WOO_STORE_URL", "https://example.com").rstrip("/")
AUTH = HTTPBasicAuth(
    os.environ.get("WOO_CONSUMER_KEY", "ck_dummy"),
    os.environ.get("WOO_CONSUMER_SECRET", "cs_dummy"),
)
LOOKBACK_SUBSCRIPTIONS = int(os.environ.get("LOOKBACK_SUBSCRIPTIONS", "200"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

RECURRING_TYPES = {"recurring_percent", "recurring_fixed_cart", "recurring_fixed_product"}
RENEWAL_META_KEY = "_subscription_renewal"


def is_renewal_order(order):
    for meta in order.get("meta_data") or []:
        if meta.get("key") == RENEWAL_META_KEY:
            return True
    return False


def money_to_minor(amount):
    return round(float(amount) * 100)


def bad_coupons_on_order(order, coupon_types_by_code):
    bad = []
    for line in order.get("coupon_lines") or []:
        code = (line.get("code") or "").lower()
        discount_type = coupon_types_by_code.get(code)
        if discount_type is not None and discount_type not in RECURRING_TYPES:
            bad.append(line)
    return bad


def decide(order, coupon_types_by_code):
    """Pure decision function. No I/O. Returns (action, reason, bad_coupon_lines)."""
    if not is_renewal_order(order):
        return ("skip", "not a renewal order", [])
    if order.get("status") in ("cancelled", "refunded", "failed", "trash"):
        return ("skip", "order is not in a state worth editing", [])
    bad = bad_coupons_on_order(order, coupon_types_by_code)
    if not bad:
        return ("skip", "no non recurring coupon on this renewal", [])
    return ("fix", "a non recurring coupon is applied to a renewal", bad)


def discount_minor_of(lines):
    total = 0
    for line in lines:
        total += money_to_minor(line.get("discount") or "0")
    return total


def get_subscriptions(per_page=50):
    page = 1
    seen = 0
    while seen < LOOKBACK_SUBSCRIPTIONS:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"per_page": per_page, "page": page, "status": "active"},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for sub in batch:
            seen += 1
            yield sub
        page += 1


def get_renewal_orders(subscription_id):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/orders",
        params={"type": "renewal"},
        auth=AUTH, timeout=30,
    )
    if r.status_code == 404:
        return []
    r.raise_for_status()
    return r.json()


def get_coupon_types(codes):
    types = {}
    for code in codes:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/coupons",
            params={"code": code},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        matches = r.json()
        if matches:
            types[code.lower()] = matches[0].get("discount_type")
    return types


def strip_coupon(order_id, bad_lines):
    for line in bad_lines:
        requests.delete(
            f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/coupons/{line['id']}",
            auth=AUTH, timeout=30,
        ).raise_for_status()
    added_back = discount_minor_of(bad_lines)
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
        json={"note": (
            "Removed a non recurring coupon that had been applied to this renewal. "
            f"Restored {added_back / 100:.2f} to the order total. Fixed by "
            "strip_bad_renewal_coupons."
        )},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    fixed = 0
    for sub in get_subscriptions():
        renewals = get_renewal_orders(sub["id"])
        if not renewals:
            continue
        codes = set()
        for order in renewals:
            for line in order.get("coupon_lines") or []:
                if line.get("code"):
                    codes.add(line["code"])
        if not codes:
            continue
        coupon_types_by_code = get_coupon_types(codes)
        for order in renewals:
            action, reason, bad_lines = decide(order, coupon_types_by_code)
            if action == "skip":
                continue
            codes_str = ", ".join(l.get("code", "?") for l in bad_lines)
            log.info(
                "Renewal order %s on subscription %s: %s (%s). %s",
                order["id"], sub["id"], reason, codes_str,
                "would fix" if DRY_RUN else "fixing",
            )
            if not DRY_RUN:
                strip_coupon(order["id"], bad_lines)
            fixed += 1
    log.info("Done. %d renewal order(s) %s.", fixed, "to fix" if DRY_RUN else "fixed")


if __name__ == "__main__":
    run()
strip-bad-renewal-coupons.js
/**
 * Detect and strip a non recurring coupon that got applied to a subscription renewal.
 *
 * WooCommerce Subscriptions only carries a coupon onto renewal orders when the
 * coupon is one of the recurring discount types (recurring_percent,
 * recurring_fixed_cart, recurring_fixed_product). A normal one time coupon
 * (percent, fixed_cart, fixed_product) should only ever discount the first,
 * parent order. If one is found sitting on a renewal, it is almost always a
 * leftover from a manual coupon add, an older Subscriptions version, or a
 * support agent applying a "first order only" code by hand. Left alone it
 * quietly discounts every future renewal forever.
 *
 * This walks the renewal orders on each subscription, finds coupons whose
 * discount type is not in the recurring set, removes the coupon line from the
 * order, and recalculates the order totals so the renewal charges the correct
 * amount next time. Safe by default. Run on a schedule or by hand against one
 * subscription.
 */
import { pathToFileURL } from "node:url";

const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const LOOKBACK_SUBSCRIPTIONS = Number(process.env.LOOKBACK_SUBSCRIPTIONS || 200);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const RECURRING_TYPES = new Set(["recurring_percent", "recurring_fixed_cart", "recurring_fixed_product"]);
const RENEWAL_META_KEY = "_subscription_renewal";

export function isRenewalOrder(order) {
  return (order.meta_data || []).some((meta) => meta.key === RENEWAL_META_KEY);
}

export function moneyToMinor(amount) {
  return Math.round(parseFloat(amount) * 100);
}

export function badCouponsOnOrder(order, couponTypesByCode) {
  const bad = [];
  for (const line of order.coupon_lines || []) {
    const code = (line.code || "").toLowerCase();
    const discountType = couponTypesByCode[code];
    if (discountType !== undefined && !RECURRING_TYPES.has(discountType)) {
      bad.push(line);
    }
  }
  return bad;
}

/** Pure decision function. No I/O. Returns [action, reason, badCouponLines]. */
export function decide(order, couponTypesByCode) {
  if (!isRenewalOrder(order)) {
    return ["skip", "not a renewal order", []];
  }
  if (["cancelled", "refunded", "failed", "trash"].includes(order.status)) {
    return ["skip", "order is not in a state worth editing", []];
  }
  const bad = badCouponsOnOrder(order, couponTypesByCode);
  if (bad.length === 0) {
    return ["skip", "no non recurring coupon on this renewal", []];
  }
  return ["fix", "a non recurring coupon is applied to a renewal", bad];
}

export function discountMinorOf(lines) {
  return lines.reduce((total, line) => total + moneyToMinor(line.discount || "0"), 0);
}

async function woo(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function* getSubscriptions() {
  let page = 1;
  let seen = 0;
  while (seen < LOOKBACK_SUBSCRIPTIONS) {
    const batch = await woo(`/subscriptions?per_page=50&page=${page}&status=active`);
    if (!batch || !batch.length) return;
    for (const sub of batch) {
      seen++;
      yield sub;
    }
    page++;
  }
}

async function getRenewalOrders(subscriptionId) {
  const result = await woo(`/subscriptions/${subscriptionId}/orders?type=renewal`);
  return result || [];
}

async function getCouponTypes(codes) {
  const types = {};
  for (const code of codes) {
    const matches = await woo(`/coupons?code=${encodeURIComponent(code)}`);
    if (matches && matches.length) {
      types[code.toLowerCase()] = matches[0].discount_type;
    }
  }
  return types;
}

async function stripCoupon(orderId, badLines) {
  for (const line of badLines) {
    await woo(`/orders/${orderId}/coupons/${line.id}`, { method: "DELETE" });
  }
  const addedBack = discountMinorOf(badLines);
  await woo(`/orders/${orderId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: "Removed a non recurring coupon that had been applied to this renewal. " +
            `Restored ${(addedBack / 100).toFixed(2)} to the order total. Fixed by ` +
            "strip-bad-renewal-coupons.",
    }),
  });
}

export async function run() {
  let fixed = 0;
  for await (const sub of getSubscriptions()) {
    const renewals = await getRenewalOrders(sub.id);
    if (!renewals.length) continue;
    const codes = new Set();
    for (const order of renewals) {
      for (const line of order.coupon_lines || []) {
        if (line.code) codes.add(line.code);
      }
    }
    if (!codes.size) continue;
    const couponTypesByCode = await getCouponTypes(codes);
    for (const order of renewals) {
      const [action, reason, badLines] = decide(order, couponTypesByCode);
      if (action === "skip") continue;
      const codesStr = badLines.map((l) => l.code || "?").join(", ");
      console.log(
        `Renewal order ${order.id} on subscription ${sub.id}: ${reason} (${codesStr}). ` +
        `${DRY_RUN ? "would fix" : "fixing"}`
      );
      if (!DRY_RUN) await stripCoupon(order.id, badLines);
      fixed++;
    }
  }
  console.log(`Done. ${fixed} renewal order(s) ${DRY_RUN ? "to fix" : "fixed"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((e) => { console.error(e); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides which coupons on live renewal orders get removed. Because we kept decide pure, the test needs no network and no store credentials. It just feeds in plain objects and checks the action.

test_wrongcoupon_decide.py
from strip_bad_renewal_coupons import decide


def renewal_order(**over):
    base = {
        "status": "processing",
        "meta_data": [{"key": "_subscription_renewal", "value": "123"}],
        "coupon_lines": [],
    }
    base.update(over)
    return base


def test_fix_when_one_time_coupon_on_renewal():
    order = renewal_order(coupon_lines=[
        {"id": 1, "code": "WELCOME10", "discount": "5.00"},
    ])
    types = {"welcome10": "percent"}
    action, reason, bad = decide(order, types)
    assert action == "fix"
    assert len(bad) == 1


def test_skip_when_coupon_is_a_recurring_type():
    order = renewal_order(coupon_lines=[
        {"id": 2, "code": "LOYAL5", "discount": "5.00"},
    ])
    types = {"loyal5": "recurring_percent"}
    assert decide(order, types)[0] == "skip"


def test_skip_when_order_is_not_a_renewal():
    order = {"status": "processing", "meta_data": [], "coupon_lines": [
        {"id": 3, "code": "WELCOME10", "discount": "5.00"},
    ]}
    types = {"welcome10": "percent"}
    assert decide(order, types)[0] == "skip"


def test_skip_when_order_is_cancelled():
    order = renewal_order(status="cancelled", coupon_lines=[
        {"id": 4, "code": "WELCOME10", "discount": "5.00"},
    ])
    types = {"welcome10": "percent"}
    assert decide(order, types)[0] == "skip"
strip-bad-renewal-coupons.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./strip-bad-renewal-coupons.js";

const renewalOrder = (over = {}) => ({
  status: "processing",
  meta_data: [{ key: "_subscription_renewal", value: "123" }],
  coupon_lines: [],
  ...over,
});

test("fix when a one time coupon is on a renewal", () => {
  const order = renewalOrder({ coupon_lines: [{ id: 1, code: "WELCOME10", discount: "5.00" }] });
  const types = { welcome10: "percent" };
  const [action, , bad] = decide(order, types);
  assert.equal(action, "fix");
  assert.equal(bad.length, 1);
});

test("skip when the coupon is a recurring type", () => {
  const order = renewalOrder({ coupon_lines: [{ id: 2, code: "LOYAL5", discount: "5.00" }] });
  const types = { loyal5: "recurring_percent" };
  assert.equal(decide(order, types)[0], "skip");
});

test("skip when the order is not a renewal", () => {
  const order = { status: "processing", meta_data: [], coupon_lines: [{ id: 3, code: "WELCOME10", discount: "5.00" }] };
  const types = { welcome10: "percent" };
  assert.equal(decide(order, types)[0], "skip");
});

test("skip when the order is cancelled", () => {
  const order = renewalOrder({ status: "cancelled", coupon_lines: [{ id: 4, code: "WELCOME10", discount: "5.00" }] });
  const types = { welcome10: "percent" };
  assert.equal(decide(order, types)[0], "skip");
});

Case studies

Support agent add

The goodwill discount that never expired

A support agent applied a one time fixed_cart coupon to smooth over a shipping delay on a subscription's current renewal. WooCommerce Subscriptions copied it forward to every renewal after that, since the coupon line was already sitting on the order when the next renewal was generated from it.

Eleven renewals later, finance noticed the subscription's revenue did not match its listed price. The script found the coupon on all eleven orders, confirmed it was a plain fixed_cart type, and stripped it from every one in a single dry run and apply pass.

Legacy coupon setup

The welcome code built the wrong way

A store built its "first purchase" coupon years before adopting Subscriptions and never revisited the type. It was a plain percent coupon, not recurring_percent, but an older store migration had copied it onto renewal orders directly, bypassing the check Subscriptions normally does at renewal time.

Running the script across all active subscriptions surfaced 34 renewal orders carrying the coupon. All were fixed in one pass, and the coupon itself was retired afterward to stop new signups from repeating the problem.

What good looks like

After this runs once, every renewal charges the subscription's real price, and the coupon that was meant for a single order stays where it belongs. Keep the script on a weekly schedule as a safety net, since a manual coupon add or an old integration can reintroduce the same problem later.

FAQ

Why is a coupon still discounting my subscription renewals?

WooCommerce Subscriptions is only supposed to keep a coupon on renewal orders when the coupon is a recurring discount type. A normal one time coupon should only ever discount the first order. If it shows up on a renewal, it was most likely applied by hand, added on an older version of Subscriptions, or set up as the wrong coupon type from the start.

Is it safe to remove a coupon from a live order with a script?

Yes, when the script only removes coupons whose discount type is confirmed to not be a recurring type, only touches orders confirmed to be renewals, and skips orders that are cancelled, refunded, or already failed. Start in dry run mode to review the exact list before it writes.

Will removing the coupon affect the customer's next renewal amount?

Yes, that is the point. The renewal order's total is recalculated without the coupon's discount, so the customer is charged the correct subscription price starting with the next renewal that runs after the fix.

Related field notes

Citations

On the problem:

  1. WooCommerce Subscriptions documentation: recurring coupon types and how they differ from one time coupons. woocommerce.com/document/subscription-coupons
  2. WooCommerce Subscriptions developer docs: how coupons are applied to renewal orders. woocommerce.com/document/subscriptions/develop/rest-api
  3. WooCommerce coupon data store: the discount_type field and its accepted values. woocommerce.github.io/code-reference/classes/WC-Coupon

On the solution:

  1. WooCommerce REST API: retrieve a subscription's related orders by type. woocommerce.github.io/subscriptions-rest-api-docs
  2. WooCommerce REST API: list coupons and read a coupon's discount_type. woocommerce.github.io/woocommerce-rest-api-docs
  3. WooCommerce REST API: remove a coupon line from an order and recalculate totals. woocommerce.github.io/woocommerce-rest-api-docs

Stuck on a tricky one?

If you have a bug in WooCommerce, WooCommerce Subscriptions, or coupons and renewals that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this fix your renewal discounts?

If this saved you a pile of undercharged renewals or an awkward finance conversation, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all WooCommerce and Stripe field notes