Repair Subscriptions and billing

Shopify selling plan deleted after an app uninstall

A subscriptions app was doing its job, then someone uninstalled it to switch providers, or because a trial ended, or during a store cleanup. Within minutes, the subscribe and save button was gone from a bunch of products. Nothing else changed. The product is still there, the price is still there, but the option to buy it as a subscription vanished. Here is why removing the app took the selling plan group with it, and a small script that finds the affected products and rebuilds a group you own.

Python and Node.js Admin GraphQL API Safe by default (dry run)
A calendar on a table
Photo by Road Ahead on Unsplash
The short answer

Most subscriptions apps create and own their own SellingPlanGroup. When you uninstall the app, Shopify deletes every selling plan group that app owns, whether or not customers are still using it. Any product whose only selling plan group belonged to that app loses its subscribe and save option instantly. Run a small Python or Node.js script that lists products carrying a confirmation tag for "this should be subscribable," keeps only the ones whose selling plan group count has dropped to zero, and calls sellingPlanGroupCreate then sellingPlanGroupAddProducts to rebuild a merchant-owned group and reattach it. Full code, tests, and a dry run guard are below.

The problem in plain words

A selling plan group is the object that actually defines a subscription offer in Shopify: the delivery frequency, the discount, the billing policy. A product does not carry its own subscription rules directly, it just gets attached to one or more of these groups.

The catch is ownership. When an app creates a selling plan group through the Admin API, Shopify records that app as the owner. That is normal and expected while the app is installed. But Shopify's own uninstall behavior is to clean up everything an app owns, and a selling plan group is included in that cleanup. The moment the app is gone, its groups are gone too, even though the products, variants, and orders it was attached to are still sitting there completely untouched.

App uninstalled merchant clicks remove Shopify cleans up everything the app owns group is app-owned Group deleted selling plan gone Product loses subscribe option
The product itself was never touched. It lost its subscription option only because the selling plan group attached to it belonged to the app that just got removed.

Why it happens

This is Shopify behaving exactly as designed, which is what makes it so easy to miss until a customer complains. A few ways stores end up here:

Existing subscription contracts are not deleted when this happens, since a contract keeps billing off its own selling plan snapshot from the time it was created. The damage is forward looking: new customers can no longer choose the plan, because the option that renders it on the product page is gone. See the citations at the end for the exact docs on ownership and deletion behavior.

The key insight

You cannot undelete a selling plan group Shopify already removed, and you should not try to guess which products need one back. So the safe pattern is not "recreate a group for every product with subscriptions in its name." It is "recreate a group only for products a human tagged as subscribable that currently have zero selling plan groups attached." The tag is the record of intent, and the live count from Shopify is the record of what actually broke.

The fix, as a flow

We do not try to recover the deleted group or guess its old settings. We add a job that lists products carrying a confirmation tag, keeps only the ones whose selling plan group count has fallen to zero, creates one merchant-owned selling plan group with an equivalent discount and delivery policy, and attaches it to every affected product in one call. Anything that still has a group, or was never tagged, is left alone.

Scheduled job runs on a timer List tagged products subscribe-and-save Read group count sellingPlanGroupsCount Count is zero? yes no, skip Create and attach new group, own it
The script only rebuilds a group for products that are tagged as subscribable and currently show a selling plan group count of zero. Everything else is skipped.

Build it step by step

1

Get an Admin API access token

Create a custom app in your Shopify admin under Settings, Apps and sales channels, Develop apps. Give it the read_products and write_products scopes, since selling plans are managed through the products area, and install it to get an Admin API access token that starts with shpat_. Keep the token and the shop domain in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export SUBSCRIBABLE_TAG="subscribe-and-save"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export SUBSCRIBABLE_TAG="subscribe-and-save"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the Admin GraphQL API

Every call goes to one GraphQL endpoint with your token in the X-Shopify-Access-Token header. A small helper sends a query or mutation and returns the data, and raises if Shopify reports an error. We use this same helper to read products, create the replacement group, and attach it.

step2.py
import os, requests

SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"

def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]
step2.js
const SHOP = process.env.SHOPIFY_SHOP;
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN;
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}
3

List the products that should be subscribable

Ask for products carrying your confirmation tag, and read back the fields the decision needs: the title, the tags, and sellingPlanGroupsCount. We page through with a cursor so the job handles a large catalog.

step3.py
PRODUCTS_QUERY = """
query($cursor: String, $q: String!) {
  products(first: 25, after: $cursor, query: $q) {
    pageInfo { hasNextPage endCursor }
    nodes { id title tags sellingPlanGroupsCount { count } }
  }
}"""

def subscribable_products():
    q = f"tag:{SUBSCRIBABLE_TAG}"
    cursor = None
    while True:
        data = gql(PRODUCTS_QUERY, {"cursor": cursor, "q": q})["products"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]
step3.js
const PRODUCTS_QUERY = `
query($cursor: String, $q: String!) {
  products(first: 25, after: $cursor, query: $q) {
    pageInfo { hasNextPage endCursor }
    nodes { id title tags sellingPlanGroupsCount { count } }
  }
}`;

async function* subscribableProducts() {
  const q = `tag:${SUBSCRIBABLE_TAG}`;
  let cursor = null;
  while (true) {
    const data = (await gql(PRODUCTS_QUERY, { cursor, q })).products;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes a product and the required tag and returns true or false. A pure function like this is easy to read and easy to test, which we do later. The rule is strict on purpose. The product must carry the confirmation tag, and its selling plan group count must be exactly zero. If either is missing, we do not touch the product.

decide.py
def needs_selling_plan(product, required_tag):
    if required_tag not in (product.get("tags") or []):
        return False
    count = (product.get("sellingPlanGroupsCount") or {}).get("count", 0)
    return count == 0
decide.js
export function needsSellingPlan(product, requiredTag) {
  if (!(product.tags || []).includes(requiredTag)) return false;
  const count = product.sellingPlanGroupsCount?.count ?? 0;
  return count === 0;
}
5

Rebuild the group and re-point the products

When products are affected, create one merchant-owned SellingPlanGroup with a recurring billing and delivery policy and a percentage discount, using integer minor units for the math so rounding never drifts, then attach it to every affected product in a single sellingPlanGroupAddProducts call. Always read back userErrors. If Shopify refuses, the error tells you why, and the script should stop rather than pretend it worked.

apply.py
GROUP_CREATE = """
mutation($input: SellingPlanGroupInput!) {
  sellingPlanGroupCreate(input: $input) {
    sellingPlanGroup { id name }
    userErrors { field message }
  }
}"""

GROUP_ADD_PRODUCTS = """
mutation($id: ID!, $productIds: [ID!]!) {
  sellingPlanGroupAddProducts(id: $id, productIds: $productIds) {
    userErrors { field message }
  }
}"""

def create_group(name, percent, interval, interval_count):
    payload = selling_plan_group_input(name, percent, interval, interval_count)
    result = gql(GROUP_CREATE, {"input": payload})["sellingPlanGroupCreate"]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])
    return result["sellingPlanGroup"]["id"]

def attach_group(group_id, product_ids):
    result = gql(GROUP_ADD_PRODUCTS, {"id": group_id, "productIds": product_ids})["sellingPlanGroupAddProducts"]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])
apply.js
const GROUP_CREATE = `
mutation($input: SellingPlanGroupInput!) {
  sellingPlanGroupCreate(input: $input) {
    sellingPlanGroup { id name }
    userErrors { field message }
  }
}`;

const GROUP_ADD_PRODUCTS = `
mutation($id: ID!, $productIds: [ID!]!) {
  sellingPlanGroupAddProducts(id: $id, productIds: $productIds) {
    userErrors { field message }
  }
}`;

async function createGroup(name, percent, interval, intervalCount) {
  const payload = sellingPlanGroupInput(name, percent, interval, intervalCount);
  const result = (await gql(GROUP_CREATE, { input: payload })).sellingPlanGroupCreate;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
  return result.sellingPlanGroup.id;
}

async function attachGroup(groupId, productIds) {
  const result = (await gql(GROUP_ADD_PRODUCTS, { id: groupId, productIds })).sellingPlanGroupAddProducts;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}
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 which products it would rebuild a group for. Read the output, agree with it, then switch it off to let it write. Run it once after any app uninstall, or on a daily schedule so a missed uninstall does not sit broken for long.

Run it safe

Always start with DRY_RUN=true, and only tag a product once you have decided it should truly offer a subscription. The tag is what stops the script from creating selling plan groups on products that were never meant to have one.

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 rebuilds a group for products that carry your confirmation tag and currently show a selling plan group count of zero.

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

rebuild_selling_plan.py
"""Rebuild a Shopify selling plan group an uninstalled app took with it.

When a subscriptions app owns a SellingPlanGroup and the merchant uninstalls
that app, Shopify deletes every selling plan group the app owns. The product
survives, but productVariants().sellingPlanGroupsCount drops to zero, so the
subscribe and save option silently disappears from checkout. This scans
products that are supposed to be subscribable (tagged), finds the ones that
lost their selling plan group, recreates a merchant-owned replacement with an
equivalent policy, and reattaches it with sellingPlanGroupAddProducts. Safe
to run again and again: it only acts on products that both carry the tag and
currently have zero selling plan groups.
"""
import os
import logging
import requests

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

SHOP = os.environ.get("SHOPIFY_SHOP", "example.myshopify.com")
TOKEN = os.environ.get("SHOPIFY_ACCESS_TOKEN", "shpat_dummy")
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"
SUBSCRIBABLE_TAG = os.environ.get("SUBSCRIBABLE_TAG", "subscribe-and-save")
PLAN_NAME = os.environ.get("PLAN_NAME", "Subscribe and save")
DISCOUNT_PERCENT = float(os.environ.get("DISCOUNT_PERCENT", "10"))
DELIVERY_INTERVAL = os.environ.get("DELIVERY_INTERVAL", "MONTH")
DELIVERY_INTERVAL_COUNT = int(os.environ.get("DELIVERY_INTERVAL_COUNT", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PRODUCTS_QUERY = """
query($cursor: String, $q: String!) {
  products(first: 25, after: $cursor, query: $q) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id title tags
      sellingPlanGroupsCount { count }
      variants(first: 1) { nodes { id } }
    }
  }
}"""

GROUP_CREATE = """
mutation($input: SellingPlanGroupInput!) {
  sellingPlanGroupCreate(input: $input) {
    sellingPlanGroup { id name }
    userErrors { field message }
  }
}"""

GROUP_ADD_PRODUCTS = """
mutation($id: ID!, $productIds: [ID!]!) {
  sellingPlanGroupAddProducts(id: $id, productIds: $productIds) {
    userErrors { field message }
  }
}"""


def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]


def needs_selling_plan(product, required_tag):
    """Pure decision: does this product need its selling plan group rebuilt?

    True only when the product carries the confirmation tag (it was meant to
    be subscribable) and its current selling plan group count is zero, which
    is what an app-uninstall deletion leaves behind. Any product that still
    has a group, or was never tagged as subscribable, is left alone.
    """
    if required_tag not in (product.get("tags") or []):
        return False
    count = (product.get("sellingPlanGroupsCount") or {}).get("count", 0)
    return count == 0


def discount_minor_units(price_cents, percent):
    """Compute the discounted price in minor units (cents) for a given percent off.

    Kept as integer math throughout so the result never suffers floating
    point drift, then rounded to the nearest cent.
    """
    return round(price_cents * (100 - percent) / 100)


def selling_plan_group_input(name, percent, interval, interval_count):
    """Build the SellingPlanGroupInput payload for a simple recurring discount plan.

    Pure builder, no I/O, so it is trivial to unit test the shape of what we
    would send before ever calling Shopify.
    """
    return {
        "name": name,
        "merchantCode": name.lower().replace(" ", "-"),
        "options": ["Delivery frequency"],
        "sellingPlansToCreate": [
            {
                "name": f"Delivered every {interval_count} {interval.lower()}(s)",
                "options": [f"Every {interval_count} {interval.lower()}(s)"],
                "billingPolicy": {
                    "recurring": {
                        "interval": interval,
                        "intervalCount": interval_count,
                    }
                },
                "deliveryPolicy": {
                    "recurring": {
                        "interval": interval,
                        "intervalCount": interval_count,
                    }
                },
                "pricingPolicies": [
                    {
                        "fixed": {
                            "adjustmentType": "PERCENTAGE",
                            "adjustmentValue": {"percentage": percent},
                        }
                    }
                ],
            }
        ],
    }


def create_group(name, percent, interval, interval_count):
    payload = selling_plan_group_input(name, percent, interval, interval_count)
    result = gql(GROUP_CREATE, {"input": payload})["sellingPlanGroupCreate"]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])
    return result["sellingPlanGroup"]["id"]


def attach_group(group_id, product_ids):
    result = gql(GROUP_ADD_PRODUCTS, {"id": group_id, "productIds": product_ids})[
        "sellingPlanGroupAddProducts"
    ]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])


def subscribable_products():
    q = f"tag:{SUBSCRIBABLE_TAG}"
    cursor = None
    while True:
        data = gql(PRODUCTS_QUERY, {"cursor": cursor, "q": q})["products"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]


def run():
    broken = [p for p in subscribable_products() if needs_selling_plan(p, SUBSCRIBABLE_TAG)]
    if not broken:
        log.info("Done. 0 product(s) needed a rebuilt selling plan group.")
        return

    log.info(
        "%d product(s) lost their selling plan group. %s",
        len(broken),
        "would rebuild" if DRY_RUN else "rebuilding",
    )
    for product in broken:
        log.info("  - %s (%s)", product["title"], product["id"])

    if DRY_RUN:
        log.info("Done. %d product(s) to rebuild.", len(broken))
        return

    group_id = create_group(PLAN_NAME, DISCOUNT_PERCENT, DELIVERY_INTERVAL, DELIVERY_INTERVAL_COUNT)
    attach_group(group_id, [p["id"] for p in broken])
    log.info("Done. %d product(s) reattached to %s.", len(broken), group_id)


if __name__ == "__main__":
    run()
rebuild-selling-plan.js
/**
 * Rebuild a Shopify selling plan group an uninstalled app took with it.
 *
 * When a subscriptions app owns a SellingPlanGroup and the merchant uninstalls
 * that app, Shopify deletes every selling plan group the app owns. The product
 * survives, but productVariants().sellingPlanGroupsCount drops to zero, so the
 * subscribe and save option silently disappears from checkout. This scans
 * products that are supposed to be subscribable (tagged), finds the ones that
 * lost their selling plan group, recreates a merchant-owned replacement with an
 * equivalent policy, and reattaches it with sellingPlanGroupAddProducts. Safe
 * to run again and again.
 *
 * Guide: https://www.allanninal.dev/shopify/selling-plan-deleted-after-app-uninstall/
 */
import { pathToFileURL } from "node:url";

const SHOP = process.env.SHOPIFY_SHOP || "example.myshopify.com";
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN || "shpat_dummy";
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;
const SUBSCRIBABLE_TAG = process.env.SUBSCRIBABLE_TAG || "subscribe-and-save";
const PLAN_NAME = process.env.PLAN_NAME || "Subscribe and save";
const DISCOUNT_PERCENT = Number(process.env.DISCOUNT_PERCENT || 10);
const DELIVERY_INTERVAL = process.env.DELIVERY_INTERVAL || "MONTH";
const DELIVERY_INTERVAL_COUNT = Number(process.env.DELIVERY_INTERVAL_COUNT || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

/**
 * Pure decision: does this product need its selling plan group rebuilt?
 *
 * True only when the product carries the confirmation tag (it was meant to
 * be subscribable) and its current selling plan group count is zero, which
 * is what an app-uninstall deletion leaves behind.
 */
export function needsSellingPlan(product, requiredTag) {
  if (!(product.tags || []).includes(requiredTag)) return false;
  const count = product.sellingPlanGroupsCount?.count ?? 0;
  return count === 0;
}

/**
 * Compute the discounted price in minor units (cents) for a given percent off.
 * Kept as integer math throughout so the result never suffers floating point drift.
 */
export function discountMinorUnits(priceCents, percent) {
  return Math.round((priceCents * (100 - percent)) / 100);
}

/**
 * Build the SellingPlanGroupInput payload for a simple recurring discount plan.
 * Pure builder, no I/O, so the shape is trivial to unit test.
 */
export function sellingPlanGroupInput(name, percent, interval, intervalCount) {
  return {
    name,
    merchantCode: name.toLowerCase().replace(/\s+/g, "-"),
    options: ["Delivery frequency"],
    sellingPlansToCreate: [
      {
        name: `Delivered every ${intervalCount} ${interval.toLowerCase()}(s)`,
        options: [`Every ${intervalCount} ${interval.toLowerCase()}(s)`],
        billingPolicy: { recurring: { interval, intervalCount } },
        deliveryPolicy: { recurring: { interval, intervalCount } },
        pricingPolicies: [
          {
            fixed: {
              adjustmentType: "PERCENTAGE",
              adjustmentValue: { percentage: percent },
            },
          },
        ],
      },
    ],
  };
}

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

const PRODUCTS_QUERY = `
query($cursor: String, $q: String!) {
  products(first: 25, after: $cursor, query: $q) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id title tags
      sellingPlanGroupsCount { count }
      variants(first: 1) { nodes { id } }
    }
  }
}`;

const GROUP_CREATE = `
mutation($input: SellingPlanGroupInput!) {
  sellingPlanGroupCreate(input: $input) {
    sellingPlanGroup { id name }
    userErrors { field message }
  }
}`;

const GROUP_ADD_PRODUCTS = `
mutation($id: ID!, $productIds: [ID!]!) {
  sellingPlanGroupAddProducts(id: $id, productIds: $productIds) {
    userErrors { field message }
  }
}`;

async function* subscribableProducts() {
  const q = `tag:${SUBSCRIBABLE_TAG}`;
  let cursor = null;
  while (true) {
    const data = (await gql(PRODUCTS_QUERY, { cursor, q })).products;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}

async function createGroup(name, percent, interval, intervalCount) {
  const payload = sellingPlanGroupInput(name, percent, interval, intervalCount);
  const result = (await gql(GROUP_CREATE, { input: payload })).sellingPlanGroupCreate;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
  return result.sellingPlanGroup.id;
}

async function attachGroup(groupId, productIds) {
  const result = (await gql(GROUP_ADD_PRODUCTS, { id: groupId, productIds })).sellingPlanGroupAddProducts;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}

export async function run() {
  const broken = [];
  for await (const product of subscribableProducts()) {
    if (needsSellingPlan(product, SUBSCRIBABLE_TAG)) broken.push(product);
  }

  if (broken.length === 0) {
    console.log("Done. 0 product(s) needed a rebuilt selling plan group.");
    return;
  }

  console.log(
    `${broken.length} product(s) lost their selling plan group. ${DRY_RUN ? "would rebuild" : "rebuilding"}`
  );
  for (const product of broken) console.log(`  - ${product.title} (${product.id})`);

  if (DRY_RUN) {
    console.log(`Done. ${broken.length} product(s) to rebuild.`);
    return;
  }

  const groupId = await createGroup(PLAN_NAME, DISCOUNT_PERCENT, DELIVERY_INTERVAL, DELIVERY_INTERVAL_COUNT);
  await attachGroup(groupId, broken.map((p) => p.id));
  console.log(`Done. ${broken.length} product(s) reattached to ${groupId}.`);
}

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

Add a test

The decision rule and the group builder are the parts most worth testing, because together they decide whether a real product gets a brand new selling plan group. Because we kept needs_selling_plan and selling_plan_group_input pure, the tests need no network and no Shopify account. They just feed in plain objects and check the answer.

test_selling_plan_rebuild.py
from rebuild_selling_plan import (
    needs_selling_plan,
    discount_minor_units,
    selling_plan_group_input,
)


def product(**over):
    base = {
        "id": "gid://shopify/Product/1",
        "title": "Roast Blend",
        "tags": ["subscribe-and-save"],
        "sellingPlanGroupsCount": {"count": 1},
    }
    base.update(over)
    return base


def test_needs_rebuild_when_tagged_and_count_zero():
    assert needs_selling_plan(product(sellingPlanGroupsCount={"count": 0}), "subscribe-and-save") is True


def test_skip_when_group_still_present():
    assert needs_selling_plan(product(), "subscribe-and-save") is False


def test_skip_when_not_tagged():
    assert needs_selling_plan(product(tags=[]), "subscribe-and-save") is False


def test_discount_minor_units_rounds_to_nearest_cent():
    assert discount_minor_units(1999, 10) == 1799


def test_selling_plan_group_input_shape():
    payload = selling_plan_group_input("Subscribe and save", 10, "MONTH", 1)
    assert payload["name"] == "Subscribe and save"
    plan = payload["sellingPlansToCreate"][0]
    assert plan["billingPolicy"]["recurring"]["interval"] == "MONTH"
    assert plan["pricingPolicies"][0]["fixed"]["adjustmentValue"]["percentage"] == 10
rebuild-selling-plan.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import {
  needsSellingPlan,
  discountMinorUnits,
  sellingPlanGroupInput,
} from "./rebuild-selling-plan.js";

const product = (over = {}) => ({
  id: "gid://shopify/Product/1",
  title: "Roast Blend",
  tags: ["subscribe-and-save"],
  sellingPlanGroupsCount: { count: 1 },
  ...over,
});

test("needs rebuild when tagged and count zero", () => {
  assert.equal(needsSellingPlan(product({ sellingPlanGroupsCount: { count: 0 } }), "subscribe-and-save"), true);
});

test("skip when group still present", () => {
  assert.equal(needsSellingPlan(product(), "subscribe-and-save"), false);
});

test("skip when not tagged", () => {
  assert.equal(needsSellingPlan(product({ tags: [] }), "subscribe-and-save"), false);
});

test("discountMinorUnits rounds to nearest cent", () => {
  assert.equal(discountMinorUnits(1999, 10), 1799);
});

test("sellingPlanGroupInput shape", () => {
  const payload = sellingPlanGroupInput("Subscribe and save", 10, "MONTH", 1);
  assert.equal(payload.name, "Subscribe and save");
  const plan = payload.sellingPlansToCreate[0];
  assert.equal(plan.billingPolicy.recurring.interval, "MONTH");
  assert.equal(plan.pricingPolicies[0].fixed.adjustmentValue.percentage, 10);
});

Case studies

Switched providers

The roastery that changed subscription apps

A coffee roastery moved from one subscriptions app to another to get better analytics. They installed the new app, set everything up, then uninstalled the old one the same afternoon to tidy up their app list. By the next morning, twenty of their best selling coffees showed no subscribe option at all, because the new app had not yet been pointed at those products.

They tagged the affected products subscribe-and-save, ran the script in dry run, confirmed the list matched the twenty products, then let it create and attach a fresh group. The option came back the same day, and the new app's own groups took over from there.

Agency handoff

The store that lost subscriptions after a project wrapped

An agency set up subscribe and save for a skincare brand using an app during a redesign project. Once the project closed, the store owner cleaned out apps they no longer recognized, including that one, not realizing it was still the owner of the live selling plan group.

Support tickets started coming in asking where the subscription option went. The team tagged the subscribable products, ran the script, and had a merchant-owned replacement group live within the hour, with no dependency on any app staying installed going forward.

What good looks like

After this runs, an app uninstall stops being a silent threat to your subscription revenue. Products tagged as subscribable get a merchant-owned group back within minutes of the problem showing up, and because the group is not tied to any app, it survives the next uninstall too. Keep the tagging step deliberate, since that is what keeps the script from creating plans on products that were never meant to have one.

FAQ

Why did my Shopify subscription option disappear after I removed an app?

Most subscriptions apps create their own selling plan group and own it. When you uninstall that app, Shopify deletes every selling plan group it owns, and any product that only had that group loses its subscribe and save option immediately, with no warning.

Is it safe to rebuild a selling plan group with a script?

Yes, when the script only rebuilds a group for products that carry a confirmation tag showing they are meant to be subscribable and currently have zero selling plan groups attached. It runs in dry run first, so you see the exact list before anything is created.

What happens to existing subscription contracts when the selling plan group is deleted?

Existing subscription contracts keep billing on their own selling plan snapshot and are not deleted, but new customers can no longer select the plan on the product page, and the storefront quietly stops offering it until a new group is created and attached.

Related field notes

Citations

On the problem:

  1. Shopify Help Center: managing app permissions and what happens to app data on uninstall. help.shopify.com/en/manual/apps/app-troubleshooting/app-uninstall
  2. Shopify Help Center: selling plans and subscriptions overview. help.shopify.com/en/manual/products/purchase-options/selling-plans
  3. Shopify Community: selling plan group disappears after uninstalling a subscriptions app. community.shopify.com shopify apis and sdks

On the solution:

  1. Shopify Admin GraphQL: the sellingPlanGroupCreate mutation. shopify.dev/docs/api/admin-graphql/latest/mutations/sellingPlanGroupCreate
  2. Shopify Admin GraphQL: the sellingPlanGroupAddProducts mutation. shopify.dev/docs/api/admin-graphql/latest/mutations/sellingPlanGroupAddProducts
  3. Shopify Admin GraphQL: the Product object, including sellingPlanGroupsCount. shopify.dev/docs/api/admin-graphql/latest/objects/Product

Stuck on a tricky one?

If you have a problem in Shopify orders, payments, subscriptions, inventory, or fulfillment 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 bring your subscriptions back?

If this saved a product page from a silent broken subscribe button, 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 Shopify field notes