feat(phase-8): Billing API with server-side feature gating
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled

Add real Shopify Billing API integration: Free/Starter/Growth/Pro plans
(app/lib/billing-plans.ts, priced per PRODUCT_STRATEGY.md §6) wired into
shopify.server.ts's billing config, a merchant-facing plan page
(app/routes/app.billing.tsx) using billing.request/billing.cancel, and
webhooks.app_subscriptions.update.tsx as the durable sync path for
Shop.tier (fires even when a merchant cancels from Shopify's own billing
page, not just from this app).

Gate the features actually built so far in both loader and action (never
just hidden in the UI, so a direct POST can't bypass a tier limit):
delivery zones/rates require Growth+, the dispatch dashboard requires
Starter+, and location count is capped per tier (Free=1, Starter=3,
Growth/Pro=unlimited). Split pure tier logic (app/lib/billing-plans.ts)
from DB-backed reads/writes (app/services/billing.server.ts) so the
client-rendered UpsellState component can import the Tier type without
pulling server code into the client bundle — same split as currency.ts.

Covered by tests/unit/billing-plans.test.ts (pure tier ranking/mapping)
and tests/integration/billing.test.ts (tier persistence and location-limit
enforcement against live Postgres).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-08-24 09:29:42 -04:00
parent e6b8b710c4
commit d150509978
17 changed files with 545 additions and 8 deletions

View File

@ -68,8 +68,27 @@ submission** — don't ship without it.
Phase 0 (scaffold & CI) through Phase 7 (POS + Checkout UI extensions) are
complete — that's the full v1 launch scope per §8 of
`PRODUCT_STRATEGY.md`/§6 of `IMPLEMENTATION_PLAN.md`. Phase 8 (billing +
Built-for-Shopify hardening) is next; Phases 9-10 (v1.x fast-follow, v2) are
explicitly separate post-launch milestones in the plan, not part of v1.
Built-for-Shopify hardening) is in progress; Phases 9-10 (v1.x fast-follow,
v2) are explicitly separate post-launch milestones in the plan, not part of
v1.
**Billing (Phase 8):** real Shopify Billing API integration
(`app/shopify.server.ts`'s `billing` config, built from
`app/lib/billing-plans.ts`'s plan/price table — Free/Starter/Growth/Pro per
`PRODUCT_STRATEGY.md` §6). `app/routes/app.billing.tsx` lets a merchant
switch plans (`billing.request`) or downgrade to Free (`billing.cancel`);
`webhooks.app_subscriptions.update.tsx` is the durable sync path that keeps
`Shop.tier` correct even when a merchant cancels from Shopify's own billing
page instead of this app. Feature gates enforced **server-side** in both
loader and action (not just hidden in the UI): Delivery zones/rates need
Growth+ (`app.zones._index.tsx`, `app.rates._index.tsx`), the dispatch
dashboard needs Starter+ (`app.dashboard.tsx`, `app.dashboard.export.tsx`),
and location count is capped per tier (Free=1, Starter=3, Growth/Pro=
unlimited — `app.locations.new.tsx`, `app.locations._index.tsx`'s
seed-template action). Gated features currently only cover what's actually
built in Phases 0-7 — several Growth/Pro features listed in
`PRODUCT_STRATEGY.md` §6 (waitlists, reschedule portal, SMS, etc.) are
Phase 9/10 and not yet implemented, so aren't gated on anything yet.
**Every scheduling surface calls the same two service functions**
(`app/services/availability-request.server.ts`,

View File

@ -0,0 +1,35 @@
import { Card, EmptyState, Text } from "@shopify/polaris";
import type { Tier } from "../lib/billing-plans";
const TIER_LABEL: Record<Tier, string> = {
free: "Free",
starter: "Starter",
growth: "Growth",
pro: "Pro",
};
export function UpsellState({
requiredTier,
currentTier,
feature,
description,
}: {
requiredTier: Tier;
currentTier: Tier;
feature: string;
description: string;
}) {
return (
<Card>
<EmptyState
heading={`${feature} needs the ${TIER_LABEL[requiredTier]} plan`}
action={{ content: "View plans", url: "/app/billing" }}
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
>
<Text as="p" variant="bodyMd">
{description} You're currently on the {TIER_LABEL[currentTier]} plan.
</Text>
</EmptyState>
</Card>
);
}

54
app/lib/billing-plans.ts Normal file
View File

@ -0,0 +1,54 @@
// Pure billing/tier data and logic, safe to import from client components
// (unlike app/services/billing.server.ts, which does DB I/O and must not be
// bundled client-side — see the currency.ts precedent for why this split
// exists). Plan names/prices here are the single source of truth for both
// shopify.server.ts's billing config and services/billing.server.ts's
// AppSubscription-name-to-Tier mapping.
export type Tier = "free" | "starter" | "growth" | "pro";
export const STARTER_PLAN = "Starter";
export const GROWTH_PLAN = "Growth";
export const PRO_PLAN = "Pro";
export interface PlanPricing {
tier: Exclude<Tier, "free">;
name: string;
amount: number;
currencyCode: "USD";
trialDays: number;
}
// Prices from PRODUCT_STRATEGY.md §6. Free has no entry: Shopify's Billing
// API has no concept of a $0 plan, it's just "no active subscription".
export const PLAN_PRICING: PlanPricing[] = [
{ tier: "starter", name: STARTER_PLAN, amount: 9.99, currencyCode: "USD", trialDays: 14 },
{ tier: "growth", name: GROWTH_PLAN, amount: 19.99, currencyCode: "USD", trialDays: 14 },
{ tier: "pro", name: PRO_PLAN, amount: 39.99, currencyCode: "USD", trialDays: 14 },
];
const TIER_RANK: Record<Tier, number> = { free: 0, starter: 1, growth: 2, pro: 3 };
// Location-count ceiling per tier, per PRODUCT_STRATEGY.md §6. null = unlimited.
export const LOCATION_LIMITS: Record<Tier, number | null> = {
free: 1,
starter: 3,
growth: null,
pro: null,
};
export function tierAtLeast(tier: Tier, min: Tier): boolean {
return TIER_RANK[tier] >= TIER_RANK[min];
}
export function locationLimitFor(tier: Tier): number | null {
return LOCATION_LIMITS[tier];
}
// Inverse of PLAN_PRICING's names — maps an AppSubscription's `name` (from
// billing.check()/the app_subscriptions/update webhook) back to our
// internal Tier. Any unrecognized or absent subscription is "free".
export function tierFromPlanName(name: string | undefined | null): Tier {
const match = PLAN_PRICING.find((p) => p.name === name);
return match?.tier ?? "free";
}

127
app/routes/app.billing.tsx Normal file
View File

@ -0,0 +1,127 @@
import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useLoaderData, useNavigation } from "@remix-run/react";
import { Page, Layout, Card, BlockStack, InlineStack, Text, Button, Badge } from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import { setShopTier, tierFromPlanName } from "../services/billing.server";
import { PLAN_PRICING, type Tier } from "../lib/billing-plans";
const TIER_LABEL: Record<Tier, string> = { free: "Free", starter: "Starter", growth: "Growth", pro: "Pro" };
const FEATURES: Record<Tier, string[]> = {
free: ["1 location", "Shipping / Local Delivery / Pickup", "Date/time picker widget", "Blackout dates", "All-plan checkout enforcement"],
starter: ["Everything in Free", "Cut-off & prep-time windows", "Up to 3 locations", "Dispatch dashboard"],
growth: ["Everything in Starter", "Unlimited locations", "Delivery zones & rates", "Distance auto-assignment"],
pro: ["Everything in Growth", "Priority support"],
};
const isTest = process.env.NODE_ENV !== "production";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session, billing } = await authenticate.admin(request);
const { appSubscriptions } = await billing.check({ isTest });
const activeSubscription = appSubscriptions[0];
// Reconcile our cached Shop.tier against Shopify's own live billing state
// on every visit to this page — the app_subscriptions/update webhook is
// the durable sync path, but this catches drift immediately rather than
// waiting on webhook delivery.
const liveTier = activeSubscription ? tierFromPlanName(activeSubscription.name) : "free";
await setShopTier(session.shop, liveTier);
return { tier: liveTier, subscription: activeSubscription ?? null };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session, billing } = await authenticate.admin(request);
const formData = await request.formData();
const intent = formData.get("intent");
const returnUrl = `${new URL(request.url).origin}/app/billing`;
if (intent === "downgrade") {
const { appSubscriptions } = await billing.check({ isTest });
if (appSubscriptions[0]) {
await billing.cancel({ subscriptionId: appSubscriptions[0].id, isTest, prorate: true });
}
await setShopTier(session.shop, "free");
return data({ ok: true });
}
const plan = PLAN_PRICING.find((p) => p.tier === intent);
if (!plan) return data({ ok: false }, { status: 400 });
// billing.request() redirects to Shopify's confirmation page; it never
// returns normally (typed Promise<never>).
return billing.request({ plan: plan.name, isTest, returnUrl });
};
export default function Billing() {
const { tier, subscription } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<Page>
<TitleBar title="Billing" />
<BlockStack gap="500">
<Layout>
<Layout.Section>
<Card>
<BlockStack gap="200">
<InlineStack gap="200" blockAlign="center">
<Text as="h2" variant="headingMd">
Current plan: {TIER_LABEL[tier]}
</Text>
{subscription && <Badge tone="success">Active</Badge>}
</InlineStack>
{subscription && (
<Text as="p" tone="subdued">
Renews {new Date(subscription.currentPeriodEnd).toLocaleDateString()}.
</Text>
)}
</BlockStack>
</Card>
</Layout.Section>
<Layout.Section>
<InlineStack gap="400" wrap>
{(["free", "starter", "growth", "pro"] as const).map((planTier) => (
<div key={planTier} style={{ minWidth: 220, flex: "1 1 220px" }}>
<Card>
<BlockStack gap="300">
<InlineStack align="space-between" blockAlign="center">
<Text as="h3" variant="headingSm">
{TIER_LABEL[planTier]}
</Text>
{planTier === tier && <Badge>Current</Badge>}
</InlineStack>
<Text as="p" variant="bodySm" tone="subdued">
{planTier === "free"
? "$0"
: `$${PLAN_PRICING.find((p) => p.tier === planTier)!.amount}/mo`}
</Text>
<BlockStack gap="100">
{FEATURES[planTier].map((feature) => (
<Text as="p" variant="bodySm" key={feature}>
{feature}
</Text>
))}
</BlockStack>
{planTier !== tier && (
<Form method="post">
<input type="hidden" name="intent" value={planTier === "free" ? "downgrade" : planTier} />
<Button submit fullWidth loading={isSubmitting}>
{planTier === "free" ? "Downgrade to Free" : `Switch to ${TIER_LABEL[planTier]}`}
</Button>
</Form>
)}
</BlockStack>
</Card>
</div>
))}
</InlineStack>
</Layout.Section>
</Layout>
</BlockStack>
</Page>
);
}

View File

@ -4,12 +4,20 @@ import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { bookingsToCsv, type BookingSummary } from "../services/dashboard.server";
import { getShopTier } from "../services/billing.server";
import { tierAtLeast } from "../lib/billing-plans";
// Resource route (no default export/component) — safe to hit directly for
// a file download, and safe to import dashboard.server.ts freely since
// there's no client component here for Remix to worry about bundling it into.
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const tier = await getShopTier(session.shop);
if (!tierAtLeast(tier, "starter")) {
return new Response("The dispatch dashboard needs the Starter plan or higher.", { status: 403 });
}
const url = new URL(request.url);
const locationIdFilter = url.searchParams.get("locationId") || undefined;

View File

@ -26,12 +26,21 @@ import {
type BookingSummary,
} from "../services/dashboard.server";
import { formatPriceLabel } from "../lib/currency";
import { getShopTier } from "../services/billing.server";
import { tierAtLeast } from "../lib/billing-plans";
import { UpsellState } from "../components/UpsellState";
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
const STATUSES = ["confirmed", "fulfilled", "no_show", "cancelled"];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const tier = await getShopTier(session.shop);
if (!tierAtLeast(tier, "starter")) {
return { gated: true as const, tier };
}
const url = new URL(request.url);
const locationIdFilter = url.searchParams.get("locationId") || undefined;
@ -84,6 +93,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const upcoming = upcomingFulfillments(summaries);
return {
gated: false as const,
locations,
filters: { locationId: locationIdFilter ?? "", method: methodFilter ?? "", status: statusFilter ?? "", startDate, endDate },
byDate,
@ -96,6 +106,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const tier = await getShopTier(session.shop);
if (!tierAtLeast(tier, "starter")) {
return data({ error: "The dispatch dashboard needs the Starter plan or higher." }, { status: 403 });
}
const formData = await request.formData();
const bookingId = String(formData.get("bookingId") || "");
const status = String(formData.get("status") || "");
@ -109,11 +125,27 @@ export const action = async ({ request }: ActionFunctionArgs) => {
};
export default function Dashboard() {
const { locations, filters, byDate, revenue, utilization, upcoming, totalBookings } = useLoaderData<typeof loader>();
const loaderData = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
if (loaderData.gated) {
return (
<Page>
<TitleBar title="Dispatch dashboard" />
<UpsellState
requiredTier="starter"
currentTier={loaderData.tier}
feature="The dispatch dashboard"
description="See revenue, capacity utilization, and upcoming fulfillments across all your locations."
/>
</Page>
);
}
const { locations, filters, byDate, revenue, utilization, upcoming, totalBookings } = loaderData;
const setFilter = (key: string, value: string) => {
const next = new URLSearchParams(searchParams);
if (value) next.set(key, value);

View File

@ -20,6 +20,7 @@ import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { listVerticalTemplates, seedVerticalTemplate, type VerticalKey } from "../services/templates.server";
import { canAddLocation, getShopTier, locationLimitFor } from "../services/billing.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
@ -39,6 +40,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const intent = formData.get("intent");
if (intent === "seed-template") {
if (!(await canAddLocation(session.shop))) {
const tier = await getShopTier(session.shop);
return data(
{ error: `Your ${tier} plan allows up to ${locationLimitFor(tier)} location(s). Upgrade to add more.` },
{ status: 403 },
);
}
const vertical = formData.get("vertical") as VerticalKey;
await seedVerticalTemplate(session.shop, vertical);
return data({ ok: true });

View File

@ -1,10 +1,11 @@
import { useState } from "react";
import { redirect, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { Page, Card, BlockStack, FormLayout, TextField, Button } from "@shopify/polaris";
import { Page, Card, BlockStack, Banner, FormLayout, TextField, Button } from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { canAddLocation, getShopTier, locationLimitFor } from "../services/billing.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
@ -21,6 +22,15 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return { errors };
}
if (!(await canAddLocation(session.shop))) {
const tier = await getShopTier(session.shop);
return {
errors: {
plan: `Your ${tier} plan allows up to ${locationLimitFor(tier)} location(s). Upgrade to add more.`,
},
};
}
const location = await db.location.create({
data: { shopDomain: session.shop, name, address, timezone },
});
@ -39,6 +49,14 @@ export default function NewLocation() {
<Card>
<Form method="post">
<FormLayout>
{actionData?.errors?.plan && (
<Banner tone="warning" title="Location limit reached">
<p>
{actionData.errors.plan}{" "}
<a href="/app/billing">View plans</a>
</p>
</Banner>
)}
<TextField
label="Location name"
name="name"

View File

@ -18,6 +18,9 @@ import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { formatPriceLabel } from "../lib/currency";
import { getShopTier } from "../services/billing.server";
import { tierAtLeast } from "../lib/billing-plans";
import { UpsellState } from "../components/UpsellState";
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
const KEYED_BY_OPTIONS = [
@ -28,16 +31,27 @@ const KEYED_BY_OPTIONS = [
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const tier = await getShopTier(session.shop);
if (!tierAtLeast(tier, "growth")) {
return { gated: true as const, tier };
}
const [rates, zones] = await Promise.all([
db.rate.findMany({ where: { shopDomain: session.shop }, include: { zone: true }, orderBy: { createdAt: "asc" } }),
db.zone.findMany({ where: { shopDomain: session.shop }, include: { location: true }, orderBy: { name: "asc" } }),
]);
return { rates, zones };
return { gated: false as const, rates, zones };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const tier = await getShopTier(session.shop);
if (!tierAtLeast(tier, "growth")) {
return data({ errors: { name: "Delivery rates need the Growth plan or higher." } }, { status: 403 });
}
const formData = await request.formData();
const intent = formData.get("intent");
@ -80,10 +94,26 @@ export const action = async ({ request }: ActionFunctionArgs) => {
};
export default function RatesIndex() {
const { rates, zones } = useLoaderData<typeof loader>();
const loaderData = useLoaderData<typeof loader>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
if (loaderData.gated) {
return (
<Page>
<TitleBar title="Delivery rates" />
<UpsellState
requiredTier="growth"
currentTier={loaderData.tier}
feature="Delivery rates"
description="Price Local Delivery by zone or distance band instead of relying on Shopify's own shipping rates."
/>
</Page>
);
}
const { rates, zones } = loaderData;
if (zones.length === 0) {
return (
<Page>

View File

@ -30,6 +30,7 @@ export default function App() {
<Link to="/app/zones">Delivery zones</Link>
<Link to="/app/rates">Delivery rates</Link>
<Link to="/app/dashboard">Dispatch dashboard</Link>
<Link to="/app/billing">Billing</Link>
</NavMenu>
<Outlet />
</AppProvider>

View File

@ -16,6 +16,9 @@ import {
import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import { getShopTier } from "../services/billing.server";
import { tierAtLeast } from "../lib/billing-plans";
import { UpsellState } from "../components/UpsellState";
const ZONE_TYPES = [
{ label: "Postal / ZIP codes", value: "postal" },
@ -24,6 +27,12 @@ const ZONE_TYPES = [
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const tier = await getShopTier(session.shop);
if (!tierAtLeast(tier, "growth")) {
return { gated: true as const, tier };
}
const url = new URL(request.url);
const locationId = url.searchParams.get("locationId");
@ -41,11 +50,17 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
})
: [];
return { locations, activeLocationId, zones };
return { gated: false as const, locations, activeLocationId, zones };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const tier = await getShopTier(session.shop);
if (!tierAtLeast(tier, "growth")) {
return data({ errors: { name: "Delivery zones need the Growth plan or higher." } }, { status: 403 });
}
const formData = await request.formData();
const intent = formData.get("intent");
@ -91,11 +106,27 @@ export const action = async ({ request }: ActionFunctionArgs) => {
};
export default function ZonesIndex() {
const { locations, activeLocationId, zones } = useLoaderData<typeof loader>();
const loaderData = useLoaderData<typeof loader>();
const [, setSearchParams] = useSearchParams();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
if (loaderData.gated) {
return (
<Page>
<TitleBar title="Delivery zones" />
<UpsellState
requiredTier="growth"
currentTier={loaderData.tier}
feature="Delivery zones"
description="Route Local Delivery orders by postal code or radius, with automatic nearest-location assignment."
/>
</Page>
);
}
const { locations, activeLocationId, zones } = loaderData;
if (locations.length === 0) {
return (
<Page>

View File

@ -0,0 +1,21 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
import { setShopTier, tierFromPlanName } from "../services/billing.server";
// Fires whenever an AppSubscription's status changes (created, activated,
// cancelled, expired, frozen for non-payment, declined) — the durable,
// authoritative way to keep Shop.tier in sync, since it fires even when the
// merchant cancels from Shopify's own billing page rather than /app/billing.
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload } = await authenticate.webhook(request);
const subscription = (payload as { app_subscription?: { name?: string; status?: string } })
.app_subscription;
const tier = subscription?.status === "ACTIVE" ? tierFromPlanName(subscription.name) : "free";
await setShopTier(shop, tier);
console.log(`[billing] ${topic} for ${shop}: ${subscription?.name ?? "none"} (${subscription?.status}) -> tier=${tier}`);
return new Response();
};

View File

@ -0,0 +1,29 @@
import db from "../db.server";
import { locationLimitFor, type Tier } from "../lib/billing-plans";
export type { Tier };
export { tierAtLeast, tierFromPlanName, locationLimitFor, LOCATION_LIMITS, PLAN_PRICING } from "../lib/billing-plans";
export async function getShopTier(shopDomain: string): Promise<Tier> {
const shop = await db.shop.findUnique({ where: { shopDomain }, select: { tier: true } });
return (shop?.tier as Tier | undefined) ?? "free";
}
export async function setShopTier(shopDomain: string, tier: Tier): Promise<void> {
await db.shop.upsert({
where: { shopDomain },
create: { shopDomain, tier },
update: { tier },
});
}
export async function countLocations(shopDomain: string): Promise<number> {
return db.location.count({ where: { shopDomain } });
}
export async function canAddLocation(shopDomain: string): Promise<boolean> {
const tier = await getShopTier(shopDomain);
const limit = locationLimitFor(tier);
if (limit === null) return true;
return (await countLocations(shopDomain)) < limit;
}

View File

@ -2,10 +2,12 @@ import "@shopify/shopify-app-remix/adapters/node";
import {
ApiVersion,
AppDistribution,
BillingInterval,
shopifyApp,
} from "@shopify/shopify-app-remix/server";
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import prisma from "./db.server";
import { PLAN_PRICING } from "./lib/billing-plans";
const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY,
@ -16,6 +18,17 @@ const shopify = shopifyApp({
authPathPrefix: "/auth",
sessionStorage: new PrismaSessionStorage(prisma),
distribution: AppDistribution.AppStore,
billing: Object.fromEntries(
PLAN_PRICING.map((plan) => [
plan.name,
{
trialDays: plan.trialDays,
lineItems: [
{ amount: plan.amount, currencyCode: plan.currencyCode, interval: BillingInterval.Every30Days },
],
},
]),
),
future: {
unstable_newEmbeddedAuthStrategy: true,
expiringOfflineAccessTokens: true,

View File

@ -47,6 +47,14 @@ api_version = "2025-01"
uri = "/webhooks/orders/cancelled"
topics = ["orders/cancelled"]
# Handled by: app/routes/webhooks.app_subscriptions.update.tsx — keeps
# Shop.tier in sync with the merchant's actual billing state, including
# cancellations made from Shopify's own billing page (not just via
# /app/billing in this app).
[[webhooks.subscriptions]]
uri = "/webhooks/app_subscriptions/update"
topics = ["app_subscriptions/update"]
# Mandatory GDPR compliance topics — required for Built-for-Shopify /
# public app review. TEMPORARILY DISABLED: `shopify app dev`/`deploy`
# refuses to push these until the org has requested and been granted

View File

@ -0,0 +1,52 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import db from "../../app/db.server";
import { canAddLocation, countLocations, getShopTier, setShopTier } from "../../app/services/billing.server";
const shopDomain = "billing-integration-test.myshopify.com";
async function cleanup() {
await db.location.deleteMany({ where: { shopDomain } });
await db.shop.deleteMany({ where: { shopDomain } });
}
describe("billing.server", () => {
beforeEach(cleanup);
afterAll(async () => {
await cleanup();
await db.$disconnect();
});
it("getShopTier defaults to free when no Shop row exists yet", async () => {
expect(await getShopTier(shopDomain)).toBe("free");
});
it("setShopTier persists across reads, creating the Shop row if needed", async () => {
await setShopTier(shopDomain, "growth");
expect(await getShopTier(shopDomain)).toBe("growth");
});
it("setShopTier updates an existing Shop row rather than erroring", async () => {
await setShopTier(shopDomain, "starter");
await setShopTier(shopDomain, "pro");
expect(await getShopTier(shopDomain)).toBe("pro");
});
it("canAddLocation blocks a Free shop at its 1-location limit", async () => {
expect(await canAddLocation(shopDomain)).toBe(true);
await db.location.create({
data: { shopDomain, name: "First", address: "", timezone: "America/Toronto" },
});
expect(await countLocations(shopDomain)).toBe(1);
expect(await canAddLocation(shopDomain)).toBe(false);
});
it("canAddLocation is unlimited on Growth", async () => {
await setShopTier(shopDomain, "growth");
for (let i = 0; i < 5; i++) {
await db.location.create({
data: { shopDomain, name: `Location ${i}`, address: "", timezone: "America/Toronto" },
});
}
expect(await canAddLocation(shopDomain)).toBe(true);
});
});

View File

@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
GROWTH_PLAN,
LOCATION_LIMITS,
locationLimitFor,
PRO_PLAN,
STARTER_PLAN,
tierAtLeast,
tierFromPlanName,
} from "../../app/lib/billing-plans";
describe("tierAtLeast", () => {
it("orders tiers free < starter < growth < pro", () => {
expect(tierAtLeast("free", "free")).toBe(true);
expect(tierAtLeast("free", "starter")).toBe(false);
expect(tierAtLeast("starter", "free")).toBe(true);
expect(tierAtLeast("growth", "starter")).toBe(true);
expect(tierAtLeast("starter", "growth")).toBe(false);
expect(tierAtLeast("pro", "growth")).toBe(true);
});
});
describe("locationLimitFor", () => {
it("caps Free at 1 and Starter at 3, per PRODUCT_STRATEGY.md §6", () => {
expect(locationLimitFor("free")).toBe(1);
expect(locationLimitFor("starter")).toBe(3);
});
it("has no limit (null) for Growth and Pro", () => {
expect(locationLimitFor("growth")).toBeNull();
expect(locationLimitFor("pro")).toBeNull();
});
it("matches the exported LOCATION_LIMITS table", () => {
expect(locationLimitFor("free")).toBe(LOCATION_LIMITS.free);
});
});
describe("tierFromPlanName", () => {
it("maps each configured plan name back to its tier", () => {
expect(tierFromPlanName(STARTER_PLAN)).toBe("starter");
expect(tierFromPlanName(GROWTH_PLAN)).toBe("growth");
expect(tierFromPlanName(PRO_PLAN)).toBe("pro");
});
it("falls back to free for an unrecognized or missing plan name", () => {
expect(tierFromPlanName("Some Other Plan")).toBe("free");
expect(tierFromPlanName(undefined)).toBe("free");
expect(tierFromPlanName(null)).toBe("free");
});
});