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: