From d814ba62bb72469f4051a879a0cebfcb47ae0c1c Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 30 Oct 2026 21:15:52 -0400 Subject: [PATCH] Ask for plan approval again when the subscription was cancelled (uninstall, then reinstall) Shopify App Pricing keeps a prepaid subscription active with cancelAtEndOfCycle after an uninstall, so activeSubscription isn't null on reinstall. Treat a cancelled subscription like no plan (review 1.2.2). Co-Authored-By: Claude Opus 5.5 --- app/lib/partner.server.ts | 8 ++++++-- app/routes/app.tsx | 7 ++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/lib/partner.server.ts b/app/lib/partner.server.ts index 03b4f34..cd54e32 100644 --- a/app/lib/partner.server.ts +++ b/app/lib/partner.server.ts @@ -5,7 +5,7 @@ const PARTNER_API_VERSION = "2026-07"; const QUERY = `query OfferlystActiveSubscription($appId: ID!, $shopId: ID!) { - activeSubscription(appId: $appId, shopId: $shopId) { items { handle } currentBillingCycle { endTime } pendingUpdate { items { handle } } } + activeSubscription(appId: $appId, shopId: $shopId) { cancelAtEndOfCycle items { handle } currentBillingCycle { endTime } pendingUpdate { items { handle } } } }`; export interface ActiveSubscription { @@ -14,6 +14,8 @@ export interface ActiveSubscription { /** a scheduled change (e.g. a downgrade from a yearly plan): its plan handles and when it applies */ pendingHandles: string[]; changesAt: string | null; + /** the subscription was cancelled (app uninstalled, or cancelled in the admin) and only runs out its paid period */ + cancelled: boolean; } // Only confirmed subscriptions are cached, briefly: a merchant who just approved a plan is checked at once, @@ -39,6 +41,7 @@ export async function fetchActiveSubscription(shopGid: string, { fresh = false } const json = (await res.json().catch(() => ({}))) as { data?: { activeSubscription: { + cancelAtEndOfCycle: boolean; items: { handle: string | null }[]; currentBillingCycle: { endTime: string } | null; pendingUpdate: { items: { handle: string | null }[] } | null; @@ -59,7 +62,8 @@ export async function fetchActiveSubscription(shopGid: string, { fresh = false } const sub = { handles: handlesOf(active.items), pendingHandles, - changesAt: pendingHandles.length ? active.currentBillingCycle?.endTime || null : null, + changesAt: pendingHandles.length || active.cancelAtEndOfCycle ? active.currentBillingCycle?.endTime || null : null, + cancelled: !!active.cancelAtEndOfCycle, }; cache.set(shopGid, { at: Date.now(), sub }); return sub; diff --git a/app/routes/app.tsx b/app/routes/app.tsx index d22a8e4..f6d610e 100644 --- a/app/routes/app.tsx +++ b/app/routes/app.tsx @@ -10,8 +10,9 @@ import { fetchActiveSubscription, partnerApiConfigured } from "../lib/partner.se export const loader = async ({ request }: LoaderFunctionArgs) => { const { admin, redirect, session } = await authenticate.admin(request); - // Shopify App Pricing: a shop without a plan (new install, or reinstall after the old one was cancelled) - // goes to Shopify's plan selection page. A Partner API failure never redirects a paying merchant. + // Shopify App Pricing: a shop without a plan (new install), or whose subscription was cancelled (uninstall, + // then reinstall: Shopify keeps a prepaid period running with cancelAtEndOfCycle) goes to Shopify's plan + // selection page to approve a plan again. A Partner API failure never redirects a paying merchant. if (partnerApiConfigured()) { // the welcome link adds plan_handle after a plan is approved: skip the cache so the new plan shows at once const fresh = new URL(request.url).searchParams.has("plan_handle"); @@ -23,7 +24,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { } catch (e) { console.error("subscription check failed", e); } - if (sub === null) { + if (sub === null || sub?.cancelled) { const store = session.shop.replace(".myshopify.com", ""); const handle = process.env.SHOPIFY_APP_HANDLE || "offerlyst"; throw redirect(`https://admin.shopify.com/store/${store}/charges/${handle}/pricing_plans`, { target: "_top" });