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 <noreply@anthropic.com>
63 lines
2.9 KiB
TypeScript
63 lines
2.9 KiB
TypeScript
import db from "../db.server";
|
|
import { planCodeFromSubscription, type PlanCode } from "./plans";
|
|
import { fetchActiveSubscription, partnerApiConfigured } from "./partner.server";
|
|
|
|
type GraphQL = (query: string, options?: { variables?: Record<string, unknown> }) => Promise<Response>;
|
|
export interface AdminLike { graphql: GraphQL }
|
|
|
|
export async function gql<T = any>(admin: AdminLike, query: string, variables?: Record<string, unknown>): Promise<T> {
|
|
const res = await admin.graphql(query, variables ? { variables } : undefined);
|
|
const json = (await res.json()) as { data?: T; errors?: unknown };
|
|
if (json.errors) throw new Error(`GraphQL error: ${JSON.stringify(json.errors)}`);
|
|
return json.data as T;
|
|
}
|
|
|
|
const SHOP_QUERY = `#graphql
|
|
query BundleOffersShop {
|
|
shop { id currencyCode ianaTimezone currencyFormats { moneyFormat } }
|
|
currentAppInstallation { id activeSubscriptions { name status test } }
|
|
}`;
|
|
|
|
/** 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<PlanCode> {
|
|
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 }[];
|
|
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",
|
|
moneyFormat: data.shop.currencyFormats?.moneyFormat || "${{amount}}",
|
|
appInstallationId: data.currentAppInstallation?.id,
|
|
planCode,
|
|
uninstalledAt: null,
|
|
};
|
|
return db.shop.upsert({ where: { domain }, create: { domain, ...values }, update: values });
|
|
}
|
|
|
|
export async function getShop(domain: string) {
|
|
return db.shop.findUnique({ where: { domain } });
|
|
}
|
|
|
|
/** Shop row for an admin request; syncs from Shopify the first time (or when forced). */
|
|
export async function ensureShop(admin: AdminLike, domain: string, force = false) {
|
|
const shop = await getShop(domain);
|
|
if (shop && !force && shop.appInstallationId && !shop.uninstalledAt) return shop;
|
|
return syncShop(admin, domain);
|
|
}
|