Read the plan from Shopify App Pricing (Partner API) and redirect shops without a plan to plan selection

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>
This commit is contained in:
metatroncubeswdev 2026-10-30 20:22:55 -04:00
parent 67c75feec5
commit cf500e032c
7 changed files with 108 additions and 11 deletions

View File

@ -3,6 +3,10 @@ APP_DOMAIN=bundles.example.com
SHOPIFY_API_KEY=your_client_id
SHOPIFY_API_SECRET=your_client_secret
SHOPIFY_APP_HANDLE=offerlyst
# Partner API client token with the "Manage apps" permission (reads the shop's plan from Shopify App Pricing)
SHOPIFY_PARTNER_API_ACCESS_TOKEN=
SHOPIFY_PARTNER_ORG_ID=2451848
SHOPIFY_APP_GID=gid://shopify/App/435426983937
POSTGRES_PASSWORD=change-me-to-a-long-random-string
# Encrypts order data at rest. 64 hex chars: openssl rand -hex 32. Back it up; never change it.
DATA_ENCRYPTION_KEY=

51
app/lib/partner.server.ts Normal file
View File

@ -0,0 +1,51 @@
// 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;
}

View File

@ -1,5 +1,6 @@
import db from "../db.server";
import { planCodeFromSubscription } from "./plans";
import { planCodeFromSubscription, type PlanCode } from "./plans";
import { fetchActiveSubscription, partnerApiConfigured } from "./partner.server";
type GraphQL = (query: string, options?: { variables?: Record<string, unknown> }) => Promise<Response>;
export interface AdminLike { graphql: GraphQL }
@ -13,16 +14,31 @@ export async function gql<T = any>(admin: AdminLike, query: string, variables?:
const SHOP_QUERY = `#graphql
query BundleOffersShop {
shop { currencyCode ianaTimezone currencyFormats { moneyFormat } }
shop { id currencyCode ianaTimezone currencyFormats { moneyFormat } }
currentAppInstallation { id activeSubscriptions { name status test } }
}`;
/** Creates/refreshes the Shop row from the Admin API (currency, timezone, plan). */
export async function syncShop(admin: AdminLike, domain: string) {
const data = await gql(admin, SHOP_QUERY);
/** Plan code from Shopify App Pricing (Partner API). Without Partner API credentials (local dev) it falls back
* to the Admin API's activeSubscriptions; if the Partner API fails it keeps the stored plan. */
async function resolvePlanCode(data: any, domain: string, fresh: boolean): Promise<PlanCode> {
if (partnerApiConfigured()) {
try {
const sub = await fetchActiveSubscription(data.shop.id, { fresh });
return planCodeFromSubscription(sub?.handles.join(" "));
} catch (e) {
console.error("plan lookup failed, keeping the stored plan", e);
const row = await getShop(domain);
return (row?.planCode as PlanCode) || "free";
}
}
const subs = (data.currentAppInstallation?.activeSubscriptions || []) as { name: string; status: string }[];
const active = subs.find((s) => s.status === "ACTIVE");
const planCode = planCodeFromSubscription(active?.name);
return planCodeFromSubscription(subs.find((s) => s.status === "ACTIVE")?.name);
}
/** Creates/refreshes the Shop row from the Admin API (currency, timezone) and the plan from App Pricing. */
export async function syncShop(admin: AdminLike, domain: string, { fresh = false } = {}) {
const data = await gql(admin, SHOP_QUERY);
const planCode = await resolvePlanCode(data, domain, fresh);
const values = {
currency: data.shop.currencyCode,
timezone: data.shop.ianaTimezone || "UTC",

View File

@ -3,7 +3,7 @@ import { useLoaderData } from "react-router";
import { boundary } from "@shopify/shopify-app-react-router/server";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { ensureShop, gql } from "../lib/shop.server";
import { gql, syncShop } from "../lib/shop.server";
import { currentMonthOrders, publishShop } from "../lib/publish.server";
import { planOf } from "../lib/plans";
import { moneyFormatter, offerStatus, offerSummary } from "../lib/offer-text";
@ -45,7 +45,7 @@ async function setupState(admin: Parameters<typeof gql>[0]) {
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { admin, session } = await authenticate.admin(request);
const shop = await ensureShop(admin, session.shop, true);
const shop = await syncShop(admin, session.shop, { fresh: true }); // plan straight from Shopify App Pricing
if (!shop.lastPublishedAt) await publishShop(admin, session.shop);
const plan = planOf(shop.planCode);
const now = new Date();

View File

@ -8,7 +8,7 @@ import { PLANS, planOf } from "../lib/plans";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { admin, session } = await authenticate.admin(request);
const shop = await syncShop(admin, session.shop); // refresh the plan from activeSubscriptions
const shop = await syncShop(admin, session.shop, { fresh: true }); // current plan from Shopify App Pricing
const store = session.shop.replace(".myshopify.com", "");
const handle = process.env.SHOPIFY_APP_HANDLE || "offerlyst";
return {

View File

@ -4,9 +4,31 @@ import { boundary } from "@shopify/shopify-app-react-router/server";
import { AppProvider } from "@shopify/shopify-app-react-router/react";
import { authenticate } from "../shopify.server";
import { gql } from "../lib/shop.server";
import { fetchActiveSubscription, partnerApiConfigured } from "../lib/partner.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
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)
// goes to Shopify's plan selection page. A Partner API failure never redirects a paying merchant.
if (partnerApiConfigured()) {
// 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");
let sub: Awaited<ReturnType<typeof fetchActiveSubscription>> | undefined;
try {
const { shop } = await gql(admin, `#graphql
query OfferlystShopId { shop { id } }`);
sub = await fetchActiveSubscription(shop.id, { fresh });
} catch (e) {
console.error("subscription check failed", e);
}
if (sub === null) {
const store = session.shop.replace(".myshopify.com", "");
const handle = process.env.SHOPIFY_APP_HANDLE || "offerlyst";
throw redirect(`https://admin.shopify.com/store/${store}/charges/${handle}/pricing_plans`, { target: "_top" });
}
}
// eslint-disable-next-line no-undef
return { apiKey: process.env.SHOPIFY_API_KEY || "" };

View File

@ -29,6 +29,10 @@ services:
SHOPIFY_API_SECRET: ${SHOPIFY_API_SECRET:?set SHOPIFY_API_SECRET in .env}
SHOPIFY_APP_URL: https://${APP_DOMAIN:?set APP_DOMAIN in .env}
SHOPIFY_APP_HANDLE: ${SHOPIFY_APP_HANDLE:-offerlyst}
# Shopify App Pricing: plan lookups via the Partner API (Dev Dashboard > Organization settings > Partner API clients, "Manage apps")
SHOPIFY_PARTNER_ORG_ID: ${SHOPIFY_PARTNER_ORG_ID:-2451848}
SHOPIFY_APP_GID: ${SHOPIFY_APP_GID:-gid://shopify/App/435426983937}
SHOPIFY_PARTNER_API_ACCESS_TOKEN: ${SHOPIFY_PARTNER_API_ACCESS_TOKEN:?set SHOPIFY_PARTNER_API_ACCESS_TOKEN in .env}
DATA_ENCRYPTION_KEY: ${DATA_ENCRYPTION_KEY:?set DATA_ENCRYPTION_KEY in .env (openssl rand -hex 32)}
SCOPES: read_products,write_discounts,read_discounts,read_orders,write_files,read_themes,write_app_proxy
expose: