From cf500e032c7ff040f1b6de6cf3ed3f9b212902ab Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 30 Oct 2026 20:22:55 -0400 Subject: [PATCH] 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 --- .env.example | 4 +++ app/lib/partner.server.ts | 51 +++++++++++++++++++++++++++++++++++++++ app/lib/shop.server.ts | 30 +++++++++++++++++------ app/routes/app._index.tsx | 4 +-- app/routes/app.plans.tsx | 2 +- app/routes/app.tsx | 24 +++++++++++++++++- docker-compose.yml | 4 +++ 7 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 app/lib/partner.server.ts diff --git a/.env.example b/.env.example index d7376e3..db11892 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app/lib/partner.server.ts b/app/lib/partner.server.ts new file mode 100644 index 0000000..7479b5e --- /dev/null +++ b/app/lib/partner.server.ts @@ -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(); + +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: { 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; +} diff --git a/app/lib/shop.server.ts b/app/lib/shop.server.ts index 0c85e3d..5274294 100644 --- a/app/lib/shop.server.ts +++ b/app/lib/shop.server.ts @@ -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 }) => Promise; export interface AdminLike { graphql: GraphQL } @@ -13,16 +14,31 @@ export async function gql(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 { + 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", diff --git a/app/routes/app._index.tsx b/app/routes/app._index.tsx index aee2121..2b2fc16 100644 --- a/app/routes/app._index.tsx +++ b/app/routes/app._index.tsx @@ -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[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(); diff --git a/app/routes/app.plans.tsx b/app/routes/app.plans.tsx index ae0af52..9b21623 100644 --- a/app/routes/app.plans.tsx +++ b/app/routes/app.plans.tsx @@ -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 { diff --git a/app/routes/app.tsx b/app/routes/app.tsx index b1338c2..d22a8e4 100644 --- a/app/routes/app.tsx +++ b/app/routes/app.tsx @@ -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> | 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 || "" }; diff --git a/docker-compose.yml b/docker-compose.yml index 0763c73..b5ef240 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: