bundle-offers-app/app/lib/shop.server.ts
metatroncubeswdev d723f41de5 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>
2026-10-30 21:08:58 -04:00

70 lines
3.3 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. */
interface PlanState { planCode: PlanCode; pendingPlanCode: PlanCode | null; planChangesAt: string | null }
async function resolvePlan(data: any, domain: string, fresh: boolean): Promise<PlanState> {
if (partnerApiConfigured()) {
try {
const sub = await fetchActiveSubscription(data.shop.id, { fresh });
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 { planCode: (row?.planCode as PlanCode) || "free", pendingPlanCode: null, planChangesAt: null };
}
}
const subs = (data.currentAppInstallation?.activeSubscriptions || []) as { name: string; status: string }[];
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, pendingPlanCode, planChangesAt } = await resolvePlan(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,
};
const row = await db.shop.upsert({ where: { domain }, create: { domain, ...values }, update: values });
return { ...row, pendingPlanCode, planChangesAt };
}
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);
}