// 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(); 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 { 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; }