bundle-offers-app/app/lib/partner.server.ts
metatroncubeswdev d814ba62bb 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>
2026-10-30 21:15:52 -04:00

71 lines
3.3 KiB
TypeScript

// Shopify App Pricing: the shop's plan comes from the Partner API's activeSubscription query, not from the
// Admin API (currentAppInstallation) or billing webhooks, which App Pricing doesn't use.
// https://shopify.dev/docs/apps/launch/billing/shopify-app-pricing/redirect-plan-selection-page
const PARTNER_API_VERSION = "2026-07";
const QUERY = `query OfferlystActiveSubscription($appId: ID!, $shopId: ID!) {
activeSubscription(appId: $appId, shopId: $shopId) { cancelAtEndOfCycle items { handle } currentBillingCycle { endTime } pendingUpdate { items { handle } } }
}`;
export interface ActiveSubscription {
/** plan handles of the subscription's items, e.g. ["growth"] */
handles: string[];
/** 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,
// and a cancellation is picked up when the entry expires.
const CACHE_MS = 5 * 60 * 1000;
const cache = new Map<string, { at: number; sub: ActiveSubscription }>();
export function partnerApiConfigured() {
return !!(process.env.SHOPIFY_PARTNER_ORG_ID && process.env.SHOPIFY_PARTNER_API_ACCESS_TOKEN && process.env.SHOPIFY_APP_GID);
}
/** Returns the shop's active subscription, or null when it has none. Throws when the Partner API fails,
* so callers never treat an outage or throttling as "no plan". */
export async function fetchActiveSubscription(shopGid: string, { fresh = false } = {}): Promise<ActiveSubscription | null> {
const hit = cache.get(shopGid);
if (!fresh && hit && Date.now() - hit.at < CACHE_MS) return hit.sub;
const res = await fetch(`https://partners.shopify.com/${process.env.SHOPIFY_PARTNER_ORG_ID}/api/${PARTNER_API_VERSION}/graphql.json`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Shopify-Access-Token": process.env.SHOPIFY_PARTNER_API_ACCESS_TOKEN || "" },
body: JSON.stringify({ query: QUERY, variables: { appId: process.env.SHOPIFY_APP_GID, shopId: shopGid } }),
});
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;
} | null;
};
errors?: unknown;
};
if (!res.ok || json.errors || !json.data) {
throw new Error(`Partner API request failed: ${JSON.stringify(json.errors ?? res.status)}`);
}
const active = json.data.activeSubscription;
if (!active) {
cache.delete(shopGid);
return null;
}
const handlesOf = (items: { handle: string | null }[] = []) => items.map((i) => i.handle || "").filter(Boolean);
const pendingHandles = handlesOf(active.pendingUpdate?.items);
const sub = {
handles: handlesOf(active.items),
pendingHandles,
changesAt: pendingHandles.length || active.cancelAtEndOfCycle ? active.currentBillingCycle?.endTime || null : null,
cancelled: !!active.cancelAtEndOfCycle,
};
cache.set(shopGid, { at: Date.now(), sub });
return sub;
}