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.
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.
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:
- A merchant tries a subscriptions app, imports products into it, then switches to a different app later, uninstalling the first one without first moving the selling plans over.
- A free trial of a subscriptions app ends and gets auto-removed, or a developer uninstalls a test app from a development store copy of production data.
- A store cleanup removes apps that look unused, without realizing one of them quietly owns the selling plan group behind the top selling subscription product.
- An agency or freelancer sets up subscriptions through an app during a project, then the store owner removes the app once the project ends, thinking it was just for setup.
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.
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.
Build it step by step
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.
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
// 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
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.
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"]
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;
}
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.
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"]
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;
}
}
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.
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
export function needsSellingPlan(product, requiredTag) {
if (!(product.tags || []).includes(requiredTag)) return false;
const count = product.sellingPlanGroupsCount?.count ?? 0;
return count === 0;
}
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.
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"])
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));
}
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.
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 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 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.
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
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
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.
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.
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:
- Shopify Help Center: managing app permissions and what happens to app data on uninstall. help.shopify.com/en/manual/apps/app-troubleshooting/app-uninstall
- Shopify Help Center: selling plans and subscriptions overview. help.shopify.com/en/manual/products/purchase-options/selling-plans
- Shopify Community: selling plan group disappears after uninstalling a subscriptions app. community.shopify.com shopify apis and sdks
On the solution:
- Shopify Admin GraphQL: the
sellingPlanGroupCreatemutation. shopify.dev/docs/api/admin-graphql/latest/mutations/sellingPlanGroupCreate - Shopify Admin GraphQL: the
sellingPlanGroupAddProductsmutation. shopify.dev/docs/api/admin-graphql/latest/mutations/sellingPlanGroupAddProducts - 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.
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