bundle-offers-app/app/lib/partner.server.ts
metatroncubeswdev d723f41de5 Show a scheduled plan change (e.g. yearly plan downgraded to Free) on Plans and Home
Shopify App Pricing keeps a paid yearly plan active until the billing period ends and reports the new plan as
pendingUpdate. The app keeps the paid features until then and now says when the change applies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:08:58 -04:00

67 lines
3.0 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 } 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;
}
// 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 }[];
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.currentBillingCycle?.endTime || null : null,
};
cache.set(shopGid, { at: Date.now(), sub });
return sub;
}