App Store review 1.2.2 ("fails to register the plan"): follow Offerlyst's approved
managed-pricing setup. The plan is read from the Partner API on every app load,
shops without a plan (new install or reinstall) go to Shopify's plan selection
page, and app_subscriptions/update keeps it in sync. Billing API code removed.
Reviewer's checkout video: Shopify's checkout shows pickup by location name, so
the delivery label rename never reached the shopper. New checkout-time extension
shows the chosen date and time on the thank-you page (all plans) and next to
pickup/shipping options (Plus). Checkout display copy now matches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
68 lines
3.1 KiB
TypeScript
68 lines
3.1 KiB
TypeScript
// Shopify App Pricing (managed pricing): the shop's plan comes from the Partner API's activeSubscription query,
|
|
// not from the Billing API, which App Pricing apps can't use. Same approach as Offerlyst (approved).
|
|
// https://shopify.dev/docs/apps/launch/billing/shopify-app-pricing/redirect-plan-selection-page
|
|
|
|
const PARTNER_API_VERSION = "2026-07";
|
|
|
|
const QUERY = `query DatewiseActiveSubscription($appId: ID!, $shopId: ID!) {
|
|
activeSubscription(appId: $appId, shopId: $shopId) { cancelAtEndOfCycle items { handle } }
|
|
}`;
|
|
|
|
export interface ActiveSubscription {
|
|
/** plan handles of the subscription's items, e.g. ["growth"] */
|
|
handles: string[];
|
|
/** the subscription was cancelled (app uninstalled, or cancelled in the admin) and only runs out its paid period */
|
|
cancelled: boolean;
|
|
}
|
|
|
|
// 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 }>();
|
|
|
|
/** Called on app/uninstalled: the subscription is cancelled then, so a quick reinstall must not see a cached plan. */
|
|
export function forgetSubscriptions() {
|
|
cache.clear();
|
|
}
|
|
|
|
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: { cancelAtEndOfCycle: boolean; 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),
|
|
cancelled: !!active.cancelAtEndOfCycle,
|
|
};
|
|
cache.set(shopGid, { at: Date.now(), sub });
|
|
return sub;
|
|
}
|
|
|
|
/** Shopify's hosted plan selection page for this app. */
|
|
export function pricingUrl(shopDomain: string) {
|
|
const store = shopDomain.replace(".myshopify.com", "");
|
|
return `https://admin.shopify.com/store/${store}/charges/${process.env.SHOPIFY_APP_HANDLE || "datewise-delivery"}/pricing_plans`;
|
|
}
|