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 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-30 21:15:52 -04:00
parent d723f41de5
commit d814ba62bb
2 changed files with 10 additions and 5 deletions

View File

@ -5,7 +5,7 @@
const PARTNER_API_VERSION = "2026-07"; const PARTNER_API_VERSION = "2026-07";
const QUERY = `query OfferlystActiveSubscription($appId: ID!, $shopId: ID!) { 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 { 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 */ /** a scheduled change (e.g. a downgrade from a yearly plan): its plan handles and when it applies */
pendingHandles: string[]; pendingHandles: string[];
changesAt: string | null; 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, // 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 { const json = (await res.json().catch(() => ({}))) as {
data?: { data?: {
activeSubscription: { activeSubscription: {
cancelAtEndOfCycle: boolean;
items: { handle: string | null }[]; items: { handle: string | null }[];
currentBillingCycle: { endTime: string } | null; currentBillingCycle: { endTime: string } | null;
pendingUpdate: { items: { handle: string | null }[] } | null; pendingUpdate: { items: { handle: string | null }[] } | null;
@ -59,7 +62,8 @@ export async function fetchActiveSubscription(shopGid: string, { fresh = false }
const sub = { const sub = {
handles: handlesOf(active.items), handles: handlesOf(active.items),
pendingHandles, 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 }); cache.set(shopGid, { at: Date.now(), sub });
return sub; return sub;

View File

@ -10,8 +10,9 @@ import { fetchActiveSubscription, partnerApiConfigured } from "../lib/partner.se
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const { admin, redirect, session } = await authenticate.admin(request); 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) // Shopify App Pricing: a shop without a plan (new install), or whose subscription was cancelled (uninstall,
// goes to Shopify's plan selection page. A Partner API failure never redirects a paying merchant. // 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()) { if (partnerApiConfigured()) {
// the welcome link adds plan_handle after a plan is approved: skip the cache so the new plan shows at once // 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"); const fresh = new URL(request.url).searchParams.has("plan_handle");
@ -23,7 +24,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
} catch (e) { } catch (e) {
console.error("subscription check failed", e); console.error("subscription check failed", e);
} }
if (sub === null) { if (sub === null || sub?.cancelled) {
const store = session.shop.replace(".myshopify.com", ""); const store = session.shop.replace(".myshopify.com", "");
const handle = process.env.SHOPIFY_APP_HANDLE || "offerlyst"; const handle = process.env.SHOPIFY_APP_HANDLE || "offerlyst";
throw redirect(`https://admin.shopify.com/store/${store}/charges/${handle}/pricing_plans`, { target: "_top" }); throw redirect(`https://admin.shopify.com/store/${store}/charges/${handle}/pricing_plans`, { target: "_top" });