From d723f41de50b8d545158f7c4fecd8313834900f1 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 30 Oct 2026 21:08:58 -0400 Subject: [PATCH] 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 --- .claude/skills/shopify-app-launch/SKILL.md | 4 +++- app/lib/partner.server.ts | 21 ++++++++++++++++++--- app/lib/shop.server.ts | 19 +++++++++++++------ app/routes/app._index.tsx | 3 +++ app/routes/app.plans.tsx | 15 ++++++++++++++- 5 files changed, 51 insertions(+), 11 deletions(-) diff --git a/.claude/skills/shopify-app-launch/SKILL.md b/.claude/skills/shopify-app-launch/SKILL.md index 5e60924..cc3531b 100644 --- a/.claude/skills/shopify-app-launch/SKILL.md +++ b/.claude/skills/shopify-app-launch/SKILL.md @@ -174,7 +174,8 @@ Any `orders/*` or `customers/*` webhook, or any order or customer query, trigger 2. Create the plans. Keep stable keywords in the plan names (e.g. Starter, Growth, Plus), because the app maps the subscription name to its limits. Add yearly prices if wanted, and a free trial (e.g. 14 days) on paid plans. 3. Make the Free plan public so search shows "Free plan available". 4. A private `shopify-test` plan is created automatically. Leave it alone. -5. The app's Plans page links to `https://admin.shopify.com/store//charges//pricing_plans`. +5. **Read the plan from the Partner API** (`activeSubscription`), not the Admin API or webhooks, and redirect shops with no subscription to the plan selection page from the `/app` root loader (covers install and reinstall). Needs a Partner API client token with "Manage apps". +6. The app's Plans page links to `https://admin.shopify.com/store//charges//pricing_plans`. **Checklist 7:** plans public · trial set · plan names match the code mapping · test subscription approved @@ -297,3 +298,4 @@ Go to Partner Dashboard › Distribution › **App Store review**. | Reviewer flags 2.3.1 | Shop-domain login form on `/` | Remove the form; redeploy; verify live | | Feature "doesn't work" in test | Test products out of stock or not in the collection | Use in-stock products; check the data before recording | | Chrome tab "hidden" or screenshot timeouts | Window minimised or not visible | Bring the window forward or open a new tab group; read state with JS | +| Review 1.2.2 "fails to register the plan" | App Pricing (enrolled after April 2026) does not populate `currentAppInstallation.activeSubscriptions` or send billing webhooks | Read the plan from the Partner API `activeSubscription(appId, shopId)` (client with "Manage apps"); in the `/app` root loader redirect shops with no subscription to `admin.shopify.com/store//charges//pricing_plans` (`target: "_top"`); never redirect on API errors | diff --git a/app/lib/partner.server.ts b/app/lib/partner.server.ts index 7479b5e..03b4f34 100644 --- a/app/lib/partner.server.ts +++ b/app/lib/partner.server.ts @@ -5,12 +5,15 @@ const PARTNER_API_VERSION = "2026-07"; const QUERY = `query OfferlystActiveSubscription($appId: ID!, $shopId: ID!) { - activeSubscription(appId: $appId, shopId: $shopId) { items { handle } } + 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, @@ -34,7 +37,13 @@ export async function fetchActiveSubscription(shopGid: string, { fresh = false } 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 }; + 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) { @@ -45,7 +54,13 @@ export async function fetchActiveSubscription(shopGid: string, { fresh = false } cache.delete(shopGid); return null; } - const sub = { handles: active.items.map((i) => i.handle || "").filter(Boolean) }; + 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; } diff --git a/app/lib/shop.server.ts b/app/lib/shop.server.ts index 5274294..c85cf3c 100644 --- a/app/lib/shop.server.ts +++ b/app/lib/shop.server.ts @@ -20,25 +20,31 @@ const SHOP_QUERY = `#graphql /** 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 { +interface PlanState { planCode: PlanCode; pendingPlanCode: PlanCode | null; planChangesAt: string | null } + +async function resolvePlan(data: any, domain: string, fresh: boolean): Promise { if (partnerApiConfigured()) { try { const sub = await fetchActiveSubscription(data.shop.id, { fresh }); - return planCodeFromSubscription(sub?.handles.join(" ")); + return { + planCode: planCodeFromSubscription(sub?.handles.join(" ")), + pendingPlanCode: sub?.pendingHandles.length ? planCodeFromSubscription(sub.pendingHandles.join(" ")) : null, + planChangesAt: sub?.changesAt || null, + }; } catch (e) { console.error("plan lookup failed, keeping the stored plan", e); const row = await getShop(domain); - return (row?.planCode as PlanCode) || "free"; + return { planCode: (row?.planCode as PlanCode) || "free", pendingPlanCode: null, planChangesAt: null }; } } const subs = (data.currentAppInstallation?.activeSubscriptions || []) as { name: string; status: string }[]; - return planCodeFromSubscription(subs.find((s) => s.status === "ACTIVE")?.name); + return { planCode: planCodeFromSubscription(subs.find((s) => s.status === "ACTIVE")?.name), pendingPlanCode: null, planChangesAt: null }; } /** 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 { planCode, pendingPlanCode, planChangesAt } = await resolvePlan(data, domain, fresh); const values = { currency: data.shop.currencyCode, timezone: data.shop.ianaTimezone || "UTC", @@ -47,7 +53,8 @@ export async function syncShop(admin: AdminLike, domain: string, { fresh = false planCode, uninstalledAt: null, }; - return db.shop.upsert({ where: { domain }, create: { domain, ...values }, update: values }); + const row = await db.shop.upsert({ where: { domain }, create: { domain, ...values }, update: values }); + return { ...row, pendingPlanCode, planChangesAt }; } export async function getShop(domain: string) { diff --git a/app/routes/app._index.tsx b/app/routes/app._index.tsx index 2b2fc16..674fabc 100644 --- a/app/routes/app._index.tsx +++ b/app/routes/app._index.tsx @@ -10,6 +10,7 @@ import { moneyFormatter, offerStatus, offerSummary } from "../lib/offer-text"; import { formatShopDate } from "../lib/tz"; import { CONFIG_MAX_BYTES } from "../lib/compile"; import { upcomingFestivals } from "../lib/templates"; +import { scheduledChange } from "./app.plans"; function settingsMenuLinked(settings: unknown) { return !!(settings && typeof settings === "object" && (settings as { menuLinked?: boolean }).menuLinked); @@ -60,6 +61,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { return { shop: { domain: shop.domain, publishError: shop.publishError, configBytes: shop.configBytes, monthOrders: currentMonthOrders(shop) }, plan, + scheduled: scheduledChange(shop), running: running.map((o) => ({ id: o.id, name: o.name, summary: offerSummary(o, money), ends: formatShopDate(o.endsAt, shop.timezone) })), totalOffers: offers.length, stats: { @@ -141,6 +143,7 @@ export default function Home() { {d.plan.name} + {d.scheduled ? Changes to {d.scheduled.name}{d.scheduled.date ? ` on ${d.scheduled.date}` : " at the end of this billing period"} : null} Running offers: {d.running.length} of {d.plan.runningOffers === Infinity ? "unlimited" : d.plan.runningOffers} {d.plan.monthlyOrders !== Infinity ? Bundle orders this month: {d.shop.monthOrders} of {d.plan.monthlyOrders} : null} Discount configuration: {Math.round((d.shop.configBytes / d.configMax) * 100)}% of the limit diff --git a/app/routes/app.plans.tsx b/app/routes/app.plans.tsx index 9b21623..ebf9c92 100644 --- a/app/routes/app.plans.tsx +++ b/app/routes/app.plans.tsx @@ -4,7 +4,8 @@ import { useLoaderData } from "react-router"; import { boundary } from "@shopify/shopify-app-react-router/server"; import { authenticate } from "../shopify.server"; import { syncShop } from "../lib/shop.server"; -import { PLANS, planOf } from "../lib/plans"; +import { PLANS, planOf, type PlanCode } from "../lib/plans"; +import { formatShopDate } from "../lib/tz"; export const loader = async ({ request }: LoaderFunctionArgs) => { const { admin, session } = await authenticate.admin(request); @@ -16,9 +17,16 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { pricingUrl: `https://admin.shopify.com/store/${store}/charges/${handle}/pricing_plans`, plans: Object.entries(PLANS).map(([code, p]) => ({ code, ...p, runningOffers: p.runningOffers === Infinity ? "Unlimited" : String(p.runningOffers) })), currentName: planOf(shop.planCode).name, + scheduled: scheduledChange(shop), }; }; +/** A plan change Shopify applies at the end of the billing cycle (e.g. downgrading from a yearly plan). */ +export function scheduledChange(shop: { planCode: string; pendingPlanCode: PlanCode | null; planChangesAt: string | null; timezone: string }) { + if (!shop.pendingPlanCode || shop.pendingPlanCode === shop.planCode) return null; + return { name: planOf(shop.pendingPlanCode).name, date: formatShopDate(shop.planChangesAt, shop.timezone, false) }; +} + export default function Plans() { const d = useLoaderData(); return ( @@ -26,6 +34,11 @@ export default function Plans() { Change plan You're on the {d.currentName} plan. Paid plans include a 14-day free trial; development stores are free. + {d.scheduled ? ( + + You keep the {d.currentName} features until then, because this billing period is already paid. + + ) : null}