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>
55 lines
2.0 KiB
TypeScript
55 lines
2.0 KiB
TypeScript
// 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";
|
|
}
|