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>
128 lines
5.5 KiB
TypeScript
128 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|