bundle-offers-app/app/lib/shop.server.ts
metatroncubeswdev cf500e032c Read the plan from Shopify App Pricing (Partner API) and redirect shops without a plan to plan selection
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>
2026-10-30 20:22:55 -04:00

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);
}