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>
This commit is contained in:
metatroncubeswdev 2026-10-30 21:08:58 -04:00
parent cf500e032c
commit d723f41de5
5 changed files with 51 additions and 11 deletions

View File

@ -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. 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". 3. Make the Free plan public so search shows "Free plan available".
4. A private `shopify-test` plan is created automatically. Leave it alone. 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/<store>/charges/<handle>/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/<store>/charges/<handle>/pricing_plans`.
**Checklist 7:** plans public · trial set · plan names match the code mapping · test subscription approved **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 | | 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 | | 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 | | 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/<store>/charges/<handle>/pricing_plans` (`target: "_top"`); never redirect on API errors |

View File

@ -5,12 +5,15 @@
const PARTNER_API_VERSION = "2026-07"; const PARTNER_API_VERSION = "2026-07";
const QUERY = `query OfferlystActiveSubscription($appId: ID!, $shopId: ID!) { 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 { export interface ActiveSubscription {
/** plan handles of the subscription's items, e.g. ["growth"] */ /** plan handles of the subscription's items, e.g. ["growth"] */
handles: string[]; 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, // 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 } }), body: JSON.stringify({ query: QUERY, variables: { appId: process.env.SHOPIFY_APP_GID, shopId: shopGid } }),
}); });
const json = (await res.json().catch(() => ({}))) as { 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; errors?: unknown;
}; };
if (!res.ok || json.errors || !json.data) { if (!res.ok || json.errors || !json.data) {
@ -45,7 +54,13 @@ export async function fetchActiveSubscription(shopGid: string, { fresh = false }
cache.delete(shopGid); cache.delete(shopGid);
return null; 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 }); cache.set(shopGid, { at: Date.now(), sub });
return sub; return sub;
} }

View File

@ -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 /** 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. */ * 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> { interface PlanState { planCode: PlanCode; pendingPlanCode: PlanCode | null; planChangesAt: string | null }
async function resolvePlan(data: any, domain: string, fresh: boolean): Promise<PlanState> {
if (partnerApiConfigured()) { if (partnerApiConfigured()) {
try { try {
const sub = await fetchActiveSubscription(data.shop.id, { fresh }); 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) { } catch (e) {
console.error("plan lookup failed, keeping the stored plan", e); console.error("plan lookup failed, keeping the stored plan", e);
const row = await getShop(domain); 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 }[]; 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. */ /** 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 } = {}) { export async function syncShop(admin: AdminLike, domain: string, { fresh = false } = {}) {
const data = await gql(admin, SHOP_QUERY); 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 = { const values = {
currency: data.shop.currencyCode, currency: data.shop.currencyCode,
timezone: data.shop.ianaTimezone || "UTC", timezone: data.shop.ianaTimezone || "UTC",
@ -47,7 +53,8 @@ export async function syncShop(admin: AdminLike, domain: string, { fresh = false
planCode, planCode,
uninstalledAt: null, 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) { export async function getShop(domain: string) {

View File

@ -10,6 +10,7 @@ import { moneyFormatter, offerStatus, offerSummary } from "../lib/offer-text";
import { formatShopDate } from "../lib/tz"; import { formatShopDate } from "../lib/tz";
import { CONFIG_MAX_BYTES } from "../lib/compile"; import { CONFIG_MAX_BYTES } from "../lib/compile";
import { upcomingFestivals } from "../lib/templates"; import { upcomingFestivals } from "../lib/templates";
import { scheduledChange } from "./app.plans";
function settingsMenuLinked(settings: unknown) { function settingsMenuLinked(settings: unknown) {
return !!(settings && typeof settings === "object" && (settings as { menuLinked?: boolean }).menuLinked); return !!(settings && typeof settings === "object" && (settings as { menuLinked?: boolean }).menuLinked);
@ -60,6 +61,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return { return {
shop: { domain: shop.domain, publishError: shop.publishError, configBytes: shop.configBytes, monthOrders: currentMonthOrders(shop) }, shop: { domain: shop.domain, publishError: shop.publishError, configBytes: shop.configBytes, monthOrders: currentMonthOrders(shop) },
plan, plan,
scheduled: scheduledChange(shop),
running: running.map((o) => ({ id: o.id, name: o.name, summary: offerSummary(o, money), ends: formatShopDate(o.endsAt, shop.timezone) })), running: running.map((o) => ({ id: o.id, name: o.name, summary: offerSummary(o, money), ends: formatShopDate(o.endsAt, shop.timezone) })),
totalOffers: offers.length, totalOffers: offers.length,
stats: { stats: {
@ -141,6 +143,7 @@ export default function Home() {
<s-section slot="aside" heading="Plan"> <s-section slot="aside" heading="Plan">
<s-stack gap="small-200"> <s-stack gap="small-200">
<s-text type="strong">{d.plan.name}</s-text> <s-text type="strong">{d.plan.name}</s-text>
{d.scheduled ? <s-text color="subdued">Changes to {d.scheduled.name}{d.scheduled.date ? ` on ${d.scheduled.date}` : " at the end of this billing period"}</s-text> : null}
<s-text>Running offers: {d.running.length} of {d.plan.runningOffers === Infinity ? "unlimited" : d.plan.runningOffers}</s-text> <s-text>Running offers: {d.running.length} of {d.plan.runningOffers === Infinity ? "unlimited" : d.plan.runningOffers}</s-text>
{d.plan.monthlyOrders !== Infinity ? <s-text>Bundle orders this month: {d.shop.monthOrders} of {d.plan.monthlyOrders}</s-text> : null} {d.plan.monthlyOrders !== Infinity ? <s-text>Bundle orders this month: {d.shop.monthOrders} of {d.plan.monthlyOrders}</s-text> : null}
<s-text color="subdued">Discount configuration: {Math.round((d.shop.configBytes / d.configMax) * 100)}% of the limit</s-text> <s-text color="subdued">Discount configuration: {Math.round((d.shop.configBytes / d.configMax) * 100)}% of the limit</s-text>

View File

@ -4,7 +4,8 @@ import { useLoaderData } from "react-router";
import { boundary } from "@shopify/shopify-app-react-router/server"; import { boundary } from "@shopify/shopify-app-react-router/server";
import { authenticate } from "../shopify.server"; import { authenticate } from "../shopify.server";
import { syncShop } from "../lib/shop.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) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const { admin, session } = await authenticate.admin(request); 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`, 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) })), plans: Object.entries(PLANS).map(([code, p]) => ({ code, ...p, runningOffers: p.runningOffers === Infinity ? "Unlimited" : String(p.runningOffers) })),
currentName: planOf(shop.planCode).name, 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() { export default function Plans() {
const d = useLoaderData<typeof loader>(); const d = useLoaderData<typeof loader>();
return ( return (
@ -26,6 +34,11 @@ export default function Plans() {
<s-button slot="primary-action" variant="primary" href={d.pricingUrl} target="_top">Change plan</s-button> <s-button slot="primary-action" variant="primary" href={d.pricingUrl} target="_top">Change plan</s-button>
<s-section> <s-section>
<s-paragraph>You're on the <s-text type="strong">{d.currentName}</s-text> plan. Paid plans include a 14-day free trial; development stores are free.</s-paragraph> <s-paragraph>You're on the <s-text type="strong">{d.currentName}</s-text> plan. Paid plans include a 14-day free trial; development stores are free.</s-paragraph>
{d.scheduled ? (
<s-banner tone="info" heading={`Changing to ${d.scheduled.name}${d.scheduled.date ? ` on ${d.scheduled.date}` : " at the end of this billing period"}`}>
<s-paragraph>You keep the {d.currentName} features until then, because this billing period is already paid.</s-paragraph>
</s-banner>
) : null}
</s-section> </s-section>
<s-section heading="Compare"> <s-section heading="Compare">
<s-table> <s-table>