Repair Alternative payment methods

SEPA subscriptions flip to manual renewal after an update

The mandate is still good. The customer's bank account has not changed. But after a plugin or WordPress update, a batch of SEPA Direct Debit subscriptions quietly switched from automatic to manual renewal. WooCommerce stops charging them on its own, starts emailing invoices instead, and the first sign of trouble is a wave of "why do I have to pay by hand now" messages. Here is why it happens and a small script that turns automatic billing back on for the subscriptions that still have a working mandate.

Python and Node.js Runs on a schedule Safe by default (dry run)
Paying with a bank card
Photo by Nathan Dumlao on Unsplash
The short answer

An update changed how the store reads a SEPA Direct Debit mandate that attaches to the customer a moment after the first payment, so WooCommerce Subscriptions decided the subscription had no reusable payment method and set requires_manual_renewal to true. Run a small Python or Node.js repair script on a schedule that checks Stripe for a real, attached, non-disabled SEPA payment method on the customer, and clears the manual renewal flag on any active subscription that has one. Full code, tests, and a dry run guard are below.

The problem in plain words

SEPA Direct Debit does not confirm like a card. When a customer sets one up, Stripe creates the mandate and the PaymentMethod, but the first payment can take a few seconds to a few days to fully settle. WooCommerce Subscriptions is built to store a reusable token once that PaymentMethod is confirmed attached to the Stripe customer, and to keep the subscription on automatic renewal as long as that token is there.

Some updates to the WooCommerce Subscriptions or Stripe gateway plugin changed the exact meta keys and timing they check for that saved token. When the check runs before the SEPA attachment finishes, or looks at a meta key the update renamed, the plugin sees nothing usable and falls back to manual renewal to be safe. The mandate in Stripe is completely fine. The subscription in WooCommerce just stopped trusting it.

SEPA mandate active in Stripe Plugin update changes the token check token not seen Manual renewal set on subscription No auto charge Invoice email
The mandate never breaks. The update only breaks how the store checks for it, so it plays it safe and stops billing automatically.

Why it happens

SEPA Direct Debit is asynchronous by design, and a few common causes turn that timing gap into a batch of manual renewal flips:

WooCommerce Subscriptions documents requires_manual_renewal as the single flag that decides whether the built in scheduler is allowed to attempt an automatic charge. When it is true, the scheduler skips the subscription entirely and WooCommerce falls back to emailing a renewal invoice, no matter how healthy the payment method actually is.

The key insight

Stripe is the source of truth for whether a SEPA mandate can still be charged. If Stripe shows an attached, non-disabled sepa_debit PaymentMethod on the customer, and WooCommerce has the subscription on manual renewal anyway, the subscription is wrong, not the mandate. A repair script reads the truth from Stripe and turns automatic renewal back on for the subscriptions the update should never have touched.

The fix, as a flow

We do not touch billing directly and we never force a charge. We add a job that walks active subscriptions currently on manual renewal, looks up the customer's SEPA PaymentMethod in Stripe, and if it is attached and not disabled, restores the saved payment method on the subscription and clears the manual renewal flag, the same state the subscription was in before the update touched it.

Scheduled job once a day List active subs on manual renewal Look up SEPA PaymentMethod in Stripe Attached and enabled? yes no, skip Restore auto save token + note
The repair reads the truth from Stripe and only restores automatic renewal for subscriptions whose SEPA mandate is really still attached and enabled. Everything else is left alone.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to subscriptions. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
pip install stripe requests

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

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

List subscriptions stuck on manual renewal

Use the WooCommerce REST API to page through subscriptions with status active and requires_manual_renewal set. The subscriptions endpoint lives under /wp-json/wc/v3/subscriptions when WooCommerce Subscriptions is active. We only care about ones with a Stripe customer on file, since those are the ones this bug can touch.

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 manual_renewal_subs():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": "active", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for sub in batch:
            if sub.get("requires_manual_renewal"):
                yield sub
        page += 1
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.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function* manualRenewalSubs() {
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?status=active&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const sub of batch) {
      if (sub.requires_manual_renewal) yield sub;
    }
    page++;
  }
}
3

Read the saved Stripe customer and check the mandate

The Stripe customer id is saved as order meta, usually _stripe_customer_id. Read the PaymentIntent from _stripe_intent_id or the order's transaction_id as a fallback, so we can confirm it really was a SEPA payment, then list the customer's PaymentMethods and keep the sepa_debit ones.

step3.py
import stripe

def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None

def customer_id_of(order):
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_customer_id" and meta.get("value"):
            return meta["value"]
    return None

def active_sepa_payment_method(customer_id):
    """The first attached, non-disabled SEPA Direct Debit PaymentMethod on the customer."""
    if not customer_id:
        return None
    methods = stripe.PaymentMethod.list(customer=customer_id, type="sepa_debit")
    for pm in methods.auto_paging_iter():
        if pm.customer and not getattr(pm, "sepa_debit", None) is None:
            return pm
    return None
step3.js
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

export function customerIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_customer_id" && meta.value) return meta.value;
  }
  return null;
}

export async function activeSepaPaymentMethod(customerId) {
  if (!customerId) return null;
  for await (const pm of stripe.paymentMethods.list({ customer: customerId, type: "sepa_debit" })) {
    if (pm.customer && pm.sepa_debit) return pm;
  }
  return null;
}
4

Decide, with one pure function

Keep the decision in its own function that takes a subscription and a possible SEPA PaymentMethod and returns an action. The rule is simple. If the subscription is not really on manual renewal, skip it. If no attached, enabled SEPA PaymentMethod exists, skip it and warn, since forcing automatic billing without one would be worse than the bug. Otherwise, restore automatic renewal.

decide.py
def decide(subscription, payment_method):
    if subscription.get("status") != "active":
        return ("skip", "subscription is not active")
    if not subscription.get("requires_manual_renewal"):
        return ("skip", "already on automatic renewal")
    if payment_method is None:
        return ("hold", "no attached SEPA mandate found, leaving on manual renewal")
    if payment_method.get("disabled"):
        return ("hold", "SEPA mandate found but marked disabled")
    return ("repair", "SEPA mandate is attached and enabled, restoring automatic renewal")
decide.js
export function decide(subscription, paymentMethod) {
  if (subscription.status !== "active") return ["skip", "subscription is not active"];
  if (!subscription.requires_manual_renewal) return ["skip", "already on automatic renewal"];
  if (!paymentMethod) return ["hold", "no attached SEPA mandate found, leaving on manual renewal"];
  if (paymentMethod.disabled) return ["hold", "SEPA mandate found but marked disabled"];
  return ["repair", "SEPA mandate is attached and enabled, restoring automatic renewal"];
}
5

Restore automatic renewal and the saved token

When the action is repair, clear requires_manual_renewal on the subscription and write the PaymentMethod id back onto it as the saved token, using the same meta shape WooCommerce Subscriptions already expects. Then add an order note on the parent order so the shop manager can see it was repaired and why. We never call a charge endpoint, so no money moves.

apply.py
def restore_automatic_renewal(subscription_id, payment_method):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
        json={
            "requires_manual_renewal": False,
            "meta_data": [
                {"key": "_payment_method", "value": "stripe_sepa"},
                {"key": "_payment_method_title", "value": "SEPA Direct Debit"},
                {"key": "_stripe_source_id", "value": payment_method["id"]},
            ],
        },
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
        json={"note": f"Automatic renewal restored. Stripe confirms SEPA PaymentMethod "
                      f"{payment_method['id']} is still attached and enabled. Repaired by script."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function restoreAutomaticRenewal(subscriptionId, paymentMethod) {
  await woo(`/subscriptions/${subscriptionId}`, {
    method: "PUT",
    body: JSON.stringify({
      requires_manual_renewal: false,
      meta_data: [
        { key: "_payment_method", value: "stripe_sepa" },
        { key: "_payment_method_title", value: "SEPA Direct Debit" },
        { key: "_stripe_source_id", value: paymentMethod.id },
      ],
    }),
  });
  await woo(`/subscriptions/${subscriptionId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Automatic renewal restored. Stripe confirms SEPA PaymentMethod ` +
            `${paymentMethod.id} is still attached and enabled. Repaired by script.`,
    }),
  });
}
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, trust it, then switch it off to let it write. Run it once a day with cron, since this bug shows up after updates, not every minute.

Run it safe

Always start with DRY_RUN=true. This script changes billing behavior on live subscriptions, so you want to see its plan before it acts. Once the report looks right, turn it off.

The full code

Here is the complete repair 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 never touches a subscription that is already on automatic renewal or has no confirmed mandate.

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

restore_sepa_renewal.py
"""Restore automatic renewal for SEPA subscriptions an update flipped to manual.

An update can change how WooCommerce Subscriptions checks for a saved SEPA Direct
Debit token, so it sets requires_manual_renewal even though the mandate is still
attached in Stripe. This walks active subscriptions on manual renewal, checks Stripe
for a real attached and enabled SEPA PaymentMethod, and restores automatic renewal
for the ones that have one. Never triggers a charge. Safe to run again and again.
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth

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

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


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


def customer_id_of(subscription):
    for meta in subscription.get("meta_data") or []:
        if meta.get("key") == "_stripe_customer_id" and meta.get("value"):
            return meta["value"]
    return None


def active_sepa_payment_method(customer_id):
    """The first attached, non-disabled SEPA Direct Debit PaymentMethod on the customer."""
    if not customer_id:
        return None
    methods = stripe.PaymentMethod.list(customer=customer_id, type="sepa_debit")
    for pm in methods.auto_paging_iter():
        if pm.customer and pm.get("sepa_debit") and not pm.get("disabled"):
            return pm
    return None


def decide(subscription, payment_method):
    if subscription.get("status") != "active":
        return ("skip", "subscription is not active")
    if not subscription.get("requires_manual_renewal"):
        return ("skip", "already on automatic renewal")
    if payment_method is None:
        return ("hold", "no attached SEPA mandate found, leaving on manual renewal")
    if payment_method.get("disabled"):
        return ("hold", "SEPA mandate found but marked disabled")
    return ("repair", "SEPA mandate is attached and enabled, restoring automatic renewal")


def restore_automatic_renewal(subscription_id, payment_method):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
        json={
            "requires_manual_renewal": False,
            "meta_data": [
                {"key": "_payment_method", "value": "stripe_sepa"},
                {"key": "_payment_method_title", "value": "SEPA Direct Debit"},
                {"key": "_stripe_source_id", "value": payment_method["id"]},
            ],
        },
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
        json={"note": f"Automatic renewal restored. Stripe confirms SEPA PaymentMethod "
                      f"{payment_method['id']} is still attached and enabled. Repaired by script."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    repaired = 0
    for sub in manual_renewal_subs():
        customer_id = customer_id_of(sub)
        pm = active_sepa_payment_method(customer_id)
        action, reason = decide(sub, pm)
        if action == "skip":
            continue
        if action == "hold":
            log.warning("Subscription %s: %s", sub["id"], reason)
            continue
        log.info("Subscription %s: %s. %s", sub["id"], reason, "would repair" if DRY_RUN else "repairing")
        if not DRY_RUN:
            restore_automatic_renewal(sub["id"], pm)
        repaired += 1
    log.info("Done. %d subscription(s) %s.", repaired, "to repair" if DRY_RUN else "repaired")


if __name__ == "__main__":
    run()
restore-sepa-renewal.js
/**
 * Restore automatic renewal for SEPA subscriptions an update flipped to manual.
 *
 * An update can change how WooCommerce Subscriptions checks for a saved SEPA Direct
 * Debit token, so it sets requires_manual_renewal even though the mandate is still
 * attached in Stripe. This walks active subscriptions on manual renewal, checks
 * Stripe for a real attached and enabled SEPA PaymentMethod, and restores automatic
 * renewal for the ones that have one. Never triggers a charge. Safe to run again and again.
 */
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
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");
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

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.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

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

function customerIdOf(subscription) {
  for (const meta of subscription.meta_data || []) {
    if (meta.key === "_stripe_customer_id" && meta.value) return meta.value;
  }
  return null;
}

async function activeSepaPaymentMethod(customerId) {
  if (!customerId) return null;
  for await (const pm of stripe.paymentMethods.list({ customer: customerId, type: "sepa_debit" })) {
    if (pm.customer && pm.sepa_debit && !pm.disabled) return pm;
  }
  return null;
}

function decide(subscription, paymentMethod) {
  if (subscription.status !== "active") return ["skip", "subscription is not active"];
  if (!subscription.requires_manual_renewal) return ["skip", "already on automatic renewal"];
  if (!paymentMethod) return ["hold", "no attached SEPA mandate found, leaving on manual renewal"];
  if (paymentMethod.disabled) return ["hold", "SEPA mandate found but marked disabled"];
  return ["repair", "SEPA mandate is attached and enabled, restoring automatic renewal"];
}

async function restoreAutomaticRenewal(subscriptionId, paymentMethod) {
  await woo(`/subscriptions/${subscriptionId}`, {
    method: "PUT",
    body: JSON.stringify({
      requires_manual_renewal: false,
      meta_data: [
        { key: "_payment_method", value: "stripe_sepa" },
        { key: "_payment_method_title", value: "SEPA Direct Debit" },
        { key: "_stripe_source_id", value: paymentMethod.id },
      ],
    }),
  });
  await woo(`/subscriptions/${subscriptionId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Automatic renewal restored. Stripe confirms SEPA PaymentMethod ` +
            `${paymentMethod.id} is still attached and enabled. Repaired by script.`,
    }),
  });
}

async function run() {
  let repaired = 0;
  for await (const sub of manualRenewalSubs()) {
    const customerId = customerIdOf(sub);
    const pm = await activeSepaPaymentMethod(customerId);
    const [action, reason] = decide(sub, pm);
    if (action === "skip") continue;
    if (action === "hold") { console.warn(`Subscription ${sub.id}: ${reason}`); continue; }
    console.log(`Subscription ${sub.id}: ${reason}. ${DRY_RUN ? "would repair" : "repairing"}`);
    if (!DRY_RUN) await restoreAutomaticRenewal(sub.id, pm);
    repaired++;
  }
  console.log(`Done. ${repaired} subscription(s) ${DRY_RUN ? "to repair" : "repaired"}.`);
}

run().catch((err) => { console.error(err); process.exit(1); });

Add a test

The decision rule is the part most worth testing, because it decides whether a subscription's billing behavior gets changed. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.

test_sepa_decide.py
from restore_sepa_renewal import decide


def payment_method(**over):
    base = {"id": "pm_1", "sepa_debit": {"last4": "1234"}, "disabled": False}
    base.update(over)
    return base


def test_repair_when_manual_and_mandate_attached():
    sub = {"status": "active", "requires_manual_renewal": True}
    assert decide(sub, payment_method())[0] == "repair"


def test_skip_when_already_automatic():
    sub = {"status": "active", "requires_manual_renewal": False}
    assert decide(sub, payment_method())[0] == "skip"


def test_skip_when_not_active():
    sub = {"status": "on-hold", "requires_manual_renewal": True}
    assert decide(sub, payment_method())[0] == "skip"


def test_hold_when_no_payment_method():
    sub = {"status": "active", "requires_manual_renewal": True}
    assert decide(sub, None)[0] == "hold"


def test_hold_when_payment_method_disabled():
    sub = {"status": "active", "requires_manual_renewal": True}
    assert decide(sub, payment_method(disabled=True))[0] == "hold"
sepa-decide.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./restore-sepa-renewal.js";

const paymentMethod = (over = {}) => ({ id: "pm_1", sepa_debit: { last4: "1234" }, disabled: false, ...over });

test("repair when manual and mandate attached", () => {
  assert.equal(decide({ status: "active", requires_manual_renewal: true }, paymentMethod())[0], "repair");
});

test("skip when already automatic", () => {
  assert.equal(decide({ status: "active", requires_manual_renewal: false }, paymentMethod())[0], "skip");
});

test("skip when not active", () => {
  assert.equal(decide({ status: "on-hold", requires_manual_renewal: true }, paymentMethod())[0], "skip");
});

test("hold when no payment method", () => {
  assert.equal(decide({ status: "active", requires_manual_renewal: true }, null)[0], "hold");
});

test("hold when payment method disabled", () => {
  assert.equal(decide({ status: "active", requires_manual_renewal: true }, paymentMethod({ disabled: true }))[0], "hold");
});

Case studies

Plugin update

The update that renamed a meta key

A store updated the WooCommerce Stripe gateway during a routine maintenance window. The update changed the meta key it used to confirm a saved SEPA token, so every SEPA subscription that had been created before the update looked tokenless to the new code. Over three hundred active subscriptions flipped to manual renewal overnight, and the next morning support saw a wall of "why am I being asked to pay by hand" tickets.

The repair script ran in dry run first, listed all three hundred with a confirmed live mandate, and restored automatic renewal for every one of them in a single pass, all without a single test charge.

Partial migration

The migration that ran ahead of the mandate

A store migrated to a newer version of WooCommerce Subscriptions. The migration script checked for a saved payment token on each subscription during the update, but a handful of SEPA subscriptions had just been created and their mandate attachment webhook had not landed yet. Those few were flipped to manual renewal even though the mandate arrived seconds later.

Running the repair script the next day found the mandates were now attached and enabled in Stripe, and quietly turned automatic renewal back on for exactly those subscriptions, leaving the rest of the store untouched.

What good looks like

After this runs once a day, an update that misreads a SEPA token stops turning into weeks of manual invoices and awkward customer emails. The worst case becomes a short delay before the repair script notices and restores automatic billing. Keep it running even after the plugin is patched, since a future update can make the same mistake again.

FAQ

Why did my SEPA subscriptions switch to manual renewal after an update?

SEPA Direct Debit mandates attach to the customer in a delayed step, after the first payment confirms. Some WooCommerce Subscriptions and Stripe gateway updates changed how that delayed attachment is read, so the plugin decides no reusable payment method exists and sets requires_manual_renewal to true, even though the mandate is still active in Stripe. Checking Stripe directly and clearing the flag when a reusable SEPA payment method is really there fixes it.

Is it safe to turn automatic renewal back on with a script?

Yes, when the script confirms in Stripe that the customer has a SEPA Direct Debit payment method that is attached and not disabled, and it only touches subscriptions that are active and currently on manual renewal. Start in dry run mode to review the exact list before it writes.

Will this repair charge the customer right away?

No. The script only changes the renewal setting and the saved payment method on the subscription. It does not trigger a payment. The next scheduled renewal is what charges the mandate, the same way it always has.

Related field notes

Citations

On the problem:

  1. WooCommerce Subscriptions docs: how requires_manual_renewal decides whether the scheduler can bill automatically. woocommerce.com/document/subscriptions/renewal-process
  2. Stripe docs: SEPA Direct Debit payments confirm asynchronously and the PaymentMethod attaches after the first payment. docs.stripe.com/payments/sepa-debit
  3. WooCommerce Stripe gateway changelog: entries covering saved payment method and token handling changes across releases. woocommerce.com/products/stripe

On the solution:

  1. Stripe API: list a customer's PaymentMethods and check attachment and mandate status. docs.stripe.com/api/payment_methods/list
  2. Stripe docs: SEPA Direct Debit mandates and how to confirm a mandate is still active. docs.stripe.com/mandates
  3. WooCommerce REST API: update a subscription and add an order note. woocommerce.github.io/woocommerce-rest-api-docs

Stuck on a tricky one?

If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway 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 manual renewals?

If this saved you a pile of confused customer emails or a chunk of lapsed subscriptions, 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