App Store review 1.2.2: the app didn't register the chosen plan. App Pricing doesn't populate currentAppInstallation.activeSubscriptions or send billing webhooks, so the plan now comes from the Partner API activeSubscription query. Shops with no subscription (new install or reinstall) are sent to Shopify's plan selection page; Partner API failures never redirect or downgrade. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
52 lines
2.4 KiB
TypeScript
52 lines
2.4 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) { items { handle } }
|
|
}`;
|
|
|
|
export interface ActiveSubscription {
|
|
/** plan handles of the subscription's items, e.g. ["growth"] */
|
|
handles: string[];
|
|
}
|
|
|
|
// 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: { items: { handle: string | 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 sub = { handles: active.items.map((i) => i.handle || "").filter(Boolean) };
|
|
cache.set(shopGid, { at: Date.now(), sub });
|
|
return sub;
|
|
}
|