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