From d150509978aed529b9aff628f643b81cae73d981 Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Mon, 24 Aug 2026 09:29:42 -0400 Subject: [PATCH] feat(phase-8): Billing API with server-side feature gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 23 +++- app/components/UpsellState.tsx | 35 +++++ app/lib/billing-plans.ts | 54 ++++++++ app/routes/app.billing.tsx | 127 ++++++++++++++++++ app/routes/app.dashboard.export.tsx | 8 ++ app/routes/app.dashboard.tsx | 34 ++++- app/routes/app.locations._index.tsx | 8 ++ app/routes/app.locations.new.tsx | 20 ++- app/routes/app.rates._index.tsx | 34 ++++- app/routes/app.tsx | 1 + app/routes/app.zones._index.tsx | 35 ++++- .../webhooks.app_subscriptions.update.tsx | 21 +++ app/services/billing.server.ts | 29 ++++ app/shopify.server.ts | 13 ++ shopify.app.toml | 8 ++ tests/integration/billing.test.ts | 52 +++++++ tests/unit/billing-plans.test.ts | 51 +++++++ 17 files changed, 545 insertions(+), 8 deletions(-) create mode 100644 app/components/UpsellState.tsx create mode 100644 app/lib/billing-plans.ts create mode 100644 app/routes/app.billing.tsx create mode 100644 app/routes/webhooks.app_subscriptions.update.tsx create mode 100644 app/services/billing.server.ts create mode 100644 tests/integration/billing.test.ts create mode 100644 tests/unit/billing-plans.test.ts diff --git a/README.md b/README.md index 07cad16..635c727 100644 --- a/README.md +++ b/README.md @@ -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`, diff --git a/app/components/UpsellState.tsx b/app/components/UpsellState.tsx new file mode 100644 index 0000000..5fb2783 --- /dev/null +++ b/app/components/UpsellState.tsx @@ -0,0 +1,35 @@ +import { Card, EmptyState, Text } from "@shopify/polaris"; +import type { Tier } from "../lib/billing-plans"; + +const TIER_LABEL: Record = { + free: "Free", + starter: "Starter", + growth: "Growth", + pro: "Pro", +}; + +export function UpsellState({ + requiredTier, + currentTier, + feature, + description, +}: { + requiredTier: Tier; + currentTier: Tier; + feature: string; + description: string; +}) { + return ( + + + + {description} You're currently on the {TIER_LABEL[currentTier]} plan. + + + + ); +} diff --git a/app/lib/billing-plans.ts b/app/lib/billing-plans.ts new file mode 100644 index 0000000..a2f7997 --- /dev/null +++ b/app/lib/billing-plans.ts @@ -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; + 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 = { 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 = { + 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"; +} diff --git a/app/routes/app.billing.tsx b/app/routes/app.billing.tsx new file mode 100644 index 0000000..8660fa1 --- /dev/null +++ b/app/routes/app.billing.tsx @@ -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 = { free: "Free", starter: "Starter", growth: "Growth", pro: "Pro" }; + +const FEATURES: Record = { + 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). + return billing.request({ plan: plan.name, isTest, returnUrl }); +}; + +export default function Billing() { + const { tier, subscription } = useLoaderData(); + const navigation = useNavigation(); + const isSubmitting = navigation.state === "submitting"; + + return ( + + + + + + + + + + Current plan: {TIER_LABEL[tier]} + + {subscription && Active} + + {subscription && ( + + Renews {new Date(subscription.currentPeriodEnd).toLocaleDateString()}. + + )} + + + + + + {(["free", "starter", "growth", "pro"] as const).map((planTier) => ( +
+ + + + + {TIER_LABEL[planTier]} + + {planTier === tier && Current} + + + {planTier === "free" + ? "$0" + : `$${PLAN_PRICING.find((p) => p.tier === planTier)!.amount}/mo`} + + + {FEATURES[planTier].map((feature) => ( + + • {feature} + + ))} + + {planTier !== tier && ( +
+ + +
+ )} +
+
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/app/routes/app.dashboard.export.tsx b/app/routes/app.dashboard.export.tsx index 554c49d..189cbfc 100644 --- a/app/routes/app.dashboard.export.tsx +++ b/app/routes/app.dashboard.export.tsx @@ -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; diff --git a/app/routes/app.dashboard.tsx b/app/routes/app.dashboard.tsx index ab584ae..3cb8890 100644 --- a/app/routes/app.dashboard.tsx +++ b/app/routes/app.dashboard.tsx @@ -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(); + const loaderData = useLoaderData(); const [searchParams, setSearchParams] = useSearchParams(); const navigation = useNavigation(); const isSubmitting = navigation.state === "submitting"; + if (loaderData.gated) { + return ( + + + + + ); + } + + 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); diff --git a/app/routes/app.locations._index.tsx b/app/routes/app.locations._index.tsx index 496b0c6..dbb666a 100644 --- a/app/routes/app.locations._index.tsx +++ b/app/routes/app.locations._index.tsx @@ -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 }); diff --git a/app/routes/app.locations.new.tsx b/app/routes/app.locations.new.tsx index 12bc30f..65aa5ea 100644 --- a/app/routes/app.locations.new.tsx +++ b/app/routes/app.locations.new.tsx @@ -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() {
+ {actionData?.errors?.plan && ( + +

+ {actionData.errors.plan}{" "} + View plans +

+
+ )} { 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(); + const loaderData = useLoaderData(); const navigation = useNavigation(); const isSubmitting = navigation.state === "submitting"; + if (loaderData.gated) { + return ( + + + + + ); + } + + const { rates, zones } = loaderData; + if (zones.length === 0) { return ( diff --git a/app/routes/app.tsx b/app/routes/app.tsx index db40982..2564a6c 100644 --- a/app/routes/app.tsx +++ b/app/routes/app.tsx @@ -30,6 +30,7 @@ export default function App() { Delivery zones Delivery rates Dispatch dashboard + Billing diff --git a/app/routes/app.zones._index.tsx b/app/routes/app.zones._index.tsx index c4ba297..a02f64e 100644 --- a/app/routes/app.zones._index.tsx +++ b/app/routes/app.zones._index.tsx @@ -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(); + const loaderData = useLoaderData(); const [, setSearchParams] = useSearchParams(); const navigation = useNavigation(); const isSubmitting = navigation.state === "submitting"; + if (loaderData.gated) { + return ( + + + + + ); + } + + const { locations, activeLocationId, zones } = loaderData; + if (locations.length === 0) { return ( diff --git a/app/routes/webhooks.app_subscriptions.update.tsx b/app/routes/webhooks.app_subscriptions.update.tsx new file mode 100644 index 0000000..a284f96 --- /dev/null +++ b/app/routes/webhooks.app_subscriptions.update.tsx @@ -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(); +}; diff --git a/app/services/billing.server.ts b/app/services/billing.server.ts new file mode 100644 index 0000000..0ec342c --- /dev/null +++ b/app/services/billing.server.ts @@ -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 { + 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 { + await db.shop.upsert({ + where: { shopDomain }, + create: { shopDomain, tier }, + update: { tier }, + }); +} + +export async function countLocations(shopDomain: string): Promise { + return db.location.count({ where: { shopDomain } }); +} + +export async function canAddLocation(shopDomain: string): Promise { + const tier = await getShopTier(shopDomain); + const limit = locationLimitFor(tier); + if (limit === null) return true; + return (await countLocations(shopDomain)) < limit; +} diff --git a/app/shopify.server.ts b/app/shopify.server.ts index 7cb8e53..e101d5a 100644 --- a/app/shopify.server.ts +++ b/app/shopify.server.ts @@ -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, diff --git a/shopify.app.toml b/shopify.app.toml index 2db451f..c604d5e 100644 --- a/shopify.app.toml +++ b/shopify.app.toml @@ -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 diff --git a/tests/integration/billing.test.ts b/tests/integration/billing.test.ts new file mode 100644 index 0000000..2368572 --- /dev/null +++ b/tests/integration/billing.test.ts @@ -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); + }); +}); diff --git a/tests/unit/billing-plans.test.ts b/tests/unit/billing-plans.test.ts new file mode 100644 index 0000000..3a24f7c --- /dev/null +++ b/tests/unit/billing-plans.test.ts @@ -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"); + }); +});