shopify-billing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Shopify Billing Skill

Shopify Billing Skill

The Billing API allows you to charge merchants for your app using recurring subscriptions or one-time purchases.
[!IMPORTANT] GraphQL Only: The REST Billing API is deprecated. Always use the GraphQL Admin API for billing operations.
Billing API允许你使用定期订阅或一次性购买的方式向使用你应用的商家收费。
[!IMPORTANT] 仅支持GraphQL:REST版本的Billing API已废弃,所有计费操作请始终使用GraphQL Admin API。

Billing Overview

计费概述

Shopify supports three billing models:
  1. Time-based subscriptions - Recurring charges at set intervals (30 days or annual)
  2. Usage-based subscriptions - Charges based on app usage during 30-day cycles
  3. One-time purchases - Single charges for features or services

Shopify支持三种计费模式:
  1. 基于时间的订阅 - 按固定周期(30天或按年)定期收费
  2. 按使用量订阅 - 按30天周期内的应用使用量收费
  3. 一次性购买 - 为功能或服务收取单次费用

Project Implementation Pattern

项目实现模式

This project uses
@shopify/shopify-app-remix
(v3.8+) which provides a simplified billing helper. Here's how billing is structured:
app/config/plans.ts                    → Plan configurations (prices, limits, features)
app/enums/BillingPlans.ts              → Plan enum values
app/shopify.server.ts                  → Billing config generation
app/routes/billing.subscription.tsx    → Initiate subscription request
app/routes/billing.subscription-confirm.tsx → Handle Shopify confirmation callback
app/routes/app.billing.tsx             → Billing UI and cancellation

本项目使用
@shopify/shopify-app-remix
(v3.8及以上版本),它提供了简化的计费辅助工具。计费模块的结构如下:
app/config/plans.ts                    → 套餐配置(价格、限额、功能)
app/enums/BillingPlans.ts              → 套餐枚举值
app/shopify.server.ts                  → 计费配置生成
app/routes/billing.subscription.tsx    → 发起订阅请求
app/routes/billing.subscription-confirm.tsx → 处理Shopify确认回调
app/routes/app.billing.tsx             → 计费UI和取消订阅功能

1. Plan Configuration (
app/config/plans.ts
)

1. 套餐配置(
app/config/plans.ts

Define your plans with pricing, intervals, and usage limits:
typescript
import { BillingInterval } from "@shopify/shopify-app-remix/server";

export interface PlanLimit {
  taggerOperations: number | null; // null = unlimited
  bulkOperations: number | null;
  cleanerOperations: number | null;
  aiRuleGenerator: number | null;
}

export interface PlanConfig {
  id: BillingPlans | "Free";
  name: string;
  price: number;
  interval: BillingInterval | "Forever";
  currency: string;
  features: string[];
  limits: PlanLimit;
  isAnnual: boolean;
  label?: string;
}

export const PLANS: Record<string, PlanConfig> = {
  Free: {
    id: "Free",
    name: "Free",
    price: 0,
    interval: "Forever" as const,
    currency: "USD",
    features: ["200 Tagger tags/month", "500 Bulk Operations/month"],
    limits: { taggerOperations: 200, bulkOperations: 500, ... },
    isAnnual: false,
  },
  Basic: {
    id: BillingPlans.Basic,
    name: "Basic",
    price: 4.99,
    interval: BillingInterval.Every30Days,
    currency: "USD",
    features: ["2,000 Tagger tags/month", "Unlimited AI"],
    limits: { taggerOperations: 2000, aiRuleGenerator: null, ... },
    isAnnual: false,
  },
  Pro: {
    id: BillingPlans.Pro,
    name: "Pro",
    price: 14.99,
    interval: BillingInterval.Every30Days,
    currency: "USD",
    features: ["Everything unlimited", "Priority Support"],
    limits: { taggerOperations: null, bulkOperations: null, ... },
    isAnnual: false,
    label: "Best Value",
  },
};

定义你的套餐信息,包含定价、周期和使用限额:
typescript
import { BillingInterval } from "@shopify/shopify-app-remix/server";

export interface PlanLimit {
  taggerOperations: number | null; // null = 无限制
  bulkOperations: number | null;
  cleanerOperations: number | null;
  aiRuleGenerator: number | null;
}

export interface PlanConfig {
  id: BillingPlans | "Free";
  name: string;
  price: number;
  interval: BillingInterval | "Forever";
  currency: string;
  features: string[];
  limits: PlanLimit;
  isAnnual: boolean;
  label?: string;
}

export const PLANS: Record<string, PlanConfig> = {
  Free: {
    id: "Free",
    name: "Free",
    price: 0,
    interval: "Forever" as const,
    currency: "USD",
    features: ["200 Tagger标签/月", "500批量操作/月"],
    limits: { taggerOperations: 200, bulkOperations: 500, ... },
    isAnnual: false,
  },
  Basic: {
    id: BillingPlans.Basic,
    name: "Basic",
    price: 4.99,
    interval: BillingInterval.Every30Days,
    currency: "USD",
    features: ["2000 Tagger标签/月", "无限制AI功能"],
    limits: { taggerOperations: 2000, aiRuleGenerator: null, ... },
    isAnnual: false,
  },
  Pro: {
    id: BillingPlans.Pro,
    name: "Pro",
    price: 14.99,
    interval: BillingInterval.Every30Days,
    currency: "USD",
    features: ["全部功能无限制", "优先支持"],
    limits: { taggerOperations: null, bulkOperations: null, ... },
    isAnnual: false,
    label: "性价比最高",
  },
};

2. Billing Config in
shopify.server.ts

2.
shopify.server.ts
中的计费配置

Generate billing config from PLANS for the Shopify app:
typescript
import { PLANS } from "./config/plans";

// Generate billing config from PLANS
// Format compatible with @shopify/shopify-app-remix v3.8+ billing.request()
const billingConfig: any = {};
Object.values(PLANS).forEach((plan) => {
  if (plan.id !== "Free" && plan.interval !== "Forever") {
    billingConfig[plan.id] = {
      amount: plan.price,
      currencyCode: plan.currency,
      interval: plan.interval,
      trialDays: 7,  // Free trial for all paid plans
      // lineItems can be overridden during billing.request() for coupons/discounts
      lineItems: [
        {
          interval: plan.interval,
          amount: plan.price,
          currencyCode: plan.currency,
        },
      ],
    };
  }
});

const shopify = shopifyApp({
  // ... other config
  billing: billingConfig,
});

从PLANS配置生成Shopify应用的计费配置:
typescript
import { PLANS } from "./config/plans";

// 从PLANS生成计费配置
// 格式兼容@shopify/shopify-app-remix v3.8+的billing.request()方法
const billingConfig: any = {};
Object.values(PLANS).forEach((plan) => {
  if (plan.id !== "Free" && plan.interval !== "Forever") {
    billingConfig[plan.id] = {
      amount: plan.price,
      currencyCode: plan.currency,
      interval: plan.interval,
      trialDays: 7,  // 所有付费套餐都有7天免费试用
      // lineItems可以在billing.request()调用时覆盖,用于实现优惠券/折扣
      lineItems: [
        {
          interval: plan.interval,
          amount: plan.price,
          currencyCode: plan.currency,
        },
      ],
    };
  }
});

const shopify = shopifyApp({
  // ... 其他配置
  billing: billingConfig,
});

3. Initiate Subscription (
billing.subscription.tsx
)

3. 发起订阅(
billing.subscription.tsx

Create a subscription request with optional coupon discounts:
typescript
import type { LoaderFunctionArgs } from '@remix-run/node';
import { redirect } from '@remix-run/node';
import { BillingInterval } from '@shopify/shopify-app-remix/server';
import { authenticate, BillingPlans } from '~/shopify.server';
import { getMyshopify } from '~/utils/get-myshopify';

// Coupon configuration
const COUPONS = {
  FIRST50: {
    discountPercentage: 0.5,  // 50% off
    durationLimitInIntervals: 1,  // 1 billing cycle
    oneTimeUse: true,
  },
  WELCOME30: {
    discountPercentage: 0.3,  // 30% off
    durationLimitInIntervals: 2,  // 2 billing cycles
    oneTimeUse: true,
  },
} as const;

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { billing, session } = await authenticate.admin(request);
  const myshopify = getMyshopify(session.shop);
  const url = new URL(request.url);
  const plan = url.searchParams.get('plan') as BillingPlans;
  const couponCode = url.searchParams.get('coupon')?.toUpperCase();

  // Validate plan
  const planConfig = PLANS[plan];
  if (!planConfig || planConfig.id === 'Free') {
    return { status: 0 };
  }

  // Check coupon usage
  let couponApplied = false;
  let appliedCoupon: typeof COUPONS[keyof typeof COUPONS] | null = null;

  if (couponCode && couponCode in COUPONS) {
    const coupon = COUPONS[couponCode as keyof typeof COUPONS];

    if (coupon.oneTimeUse) {
      const settings = await Settings.findOne({ shop: session.shop });
      const alreadyUsed = settings?.usedCoupons?.some(
        (c: { code: string }) => c.code === couponCode
      );

      if (alreadyUsed) {
        return redirect(`/app/billing?error=coupon_already_used&code=${couponCode}`);
      }
    }

    appliedCoupon = coupon;
    couponApplied = true;
  }

  // Build billing request
  const billingOptions: Parameters<typeof billing.request>[0] = {
    plan: plan,
    isTest: process.env.NODE_ENV !== 'production',
    returnUrl: `https://admin.shopify.com/store/${myshopify}/apps/${process.env.SHOPIFY_API_KEY}/billing/subscription-confirm?plan=${plan}${couponApplied ? `&coupon=${couponCode}` : ''}`,
    // Use STANDARD for smart replacement behavior
    // - Immediate for upgrades
    // - Deferred for downgrades
    replacementBehavior: 'STANDARD',
  };

  // Apply coupon discount via lineItems override
  if (couponApplied && appliedCoupon && couponCode) {
    if (planConfig.interval === BillingInterval.Every30Days ||
        planConfig.interval === BillingInterval.Annual) {
      billingOptions.lineItems = [
        {
          interval: planConfig.interval,
          discount: {
            durationLimitInIntervals: appliedCoupon.durationLimitInIntervals,
            value: {
              percentage: appliedCoupon.discountPercentage,
            },
          },
        },
      ];
    }
  }

  await billing.request(billingOptions);

  // Log activity
  await ActivityService.createLog({
    shop: session.shop,
    resourceType: "Billing",
    resourceId: plan,
    action: "Billing Request",
    detail: `${plan} plan subscription requested${couponApplied && appliedCoupon ? ` with coupon ${couponCode} (${appliedCoupon.discountPercentage * 100}% off)` : ''}`,
    status: "Success",
  });

  return null;
};

创建订阅请求,支持可选的优惠券折扣:
typescript
import type { LoaderFunctionArgs } from '@remix-run/node';
import { redirect } from '@remix-run/node';
import { BillingInterval } from '@shopify/shopify-app-remix/server';
import { authenticate, BillingPlans } from '~/shopify.server';
import { getMyshopify } from '~/utils/get-myshopify';

// 优惠券配置
const COUPONS = {
  FIRST50: {
    discountPercentage: 0.5,  // 5折
    durationLimitInIntervals: 1,  // 1个计费周期
    oneTimeUse: true,
  },
  WELCOME30: {
    discountPercentage: 0.3,  // 7折
    durationLimitInIntervals: 2,  // 2个计费周期
    oneTimeUse: true,
  },
} as const;

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { billing, session } = await authenticate.admin(request);
  const myshopify = getMyshopify(session.shop);
  const url = new URL(request.url);
  const plan = url.searchParams.get('plan') as BillingPlans;
  const couponCode = url.searchParams.get('coupon')?.toUpperCase();

  // 验证套餐有效性
  const planConfig = PLANS[plan];
  if (!planConfig || planConfig.id === 'Free') {
    return { status: 0 };
  }

  // 检查优惠券使用情况
  let couponApplied = false;
  let appliedCoupon: typeof COUPONS[keyof typeof COUPONS] | null = null;

  if (couponCode && couponCode in COUPONS) {
    const coupon = COUPONS[couponCode as keyof typeof COUPONS];

    if (coupon.oneTimeUse) {
      const settings = await Settings.findOne({ shop: session.shop });
      const alreadyUsed = settings?.usedCoupons?.some(
        (c: { code: string }) => c.code === couponCode
      );

      if (alreadyUsed) {
        return redirect(`/app/billing?error=coupon_already_used&code=${couponCode}`);
      }
    }

    appliedCoupon = coupon;
    couponApplied = true;
  }

  // 构建计费请求
  const billingOptions: Parameters<typeof billing.request>[0] = {
    plan: plan,
    isTest: process.env.NODE_ENV !== 'production',
    returnUrl: `https://admin.shopify.com/store/${myshopify}/apps/${process.env.SHOPIFY_API_KEY}/billing/subscription-confirm?plan=${plan}${couponApplied ? `&coupon=${couponCode}` : ''}`,
    // 使用STANDARD模式实现智能替换逻辑
    // - 升级套餐立即生效
    // - 降级套餐下一个周期生效
    replacementBehavior: 'STANDARD',
  };

  // 通过覆盖lineItems应用优惠券折扣
  if (couponApplied && appliedCoupon && couponCode) {
    if (planConfig.interval === BillingInterval.Every30Days ||
        planConfig.interval === BillingInterval.Annual) {
      billingOptions.lineItems = [
        {
          interval: planConfig.interval,
          discount: {
            durationLimitInIntervals: appliedCoupon.durationLimitInIntervals,
            value: {
              percentage: appliedCoupon.discountPercentage,
            },
          },
        },
      ];
    }
  }

  await billing.request(billingOptions);

  // 记录操作日志
  await ActivityService.createLog({
    shop: session.shop,
    resourceType: "Billing",
    resourceId: plan,
    action: "Billing Request",
    detail: `${plan} plan subscription requested${couponApplied && appliedCoupon ? ` with coupon ${couponCode} (${appliedCoupon.discountPercentage * 100}% off)` : ''}`,
    status: "Success",
  });

  return null;
};

4. Confirm Subscription (
billing.subscription-confirm.tsx
)

4. 确认订阅(
billing.subscription-confirm.tsx

Handle the callback after merchant approves the charge:
typescript
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { session, billing } = await authenticate.admin(request);
  const url = new URL(request.url);
  const plan = url.searchParams.get('plan') as BillingPlans;
  const couponCode = url.searchParams.get('coupon')?.toUpperCase();

  try {
    // Verify subscription is active
    const billingCheck = await billing.check({
      plans: [plan],
      isTest: process.env.NODE_ENV !== 'production',
    });

    if (billingCheck.hasActivePayment) {
      // Update shop's plan in database
      await shopService.updateApp(session.shop, APP_ID, {
        plan: plan,
        accessToken: session.accessToken,
      });

      // Record coupon usage
      if (couponCode) {
        await Settings.findOneAndUpdate(
          { shop: session.shop },
          {
            $push: {
              usedCoupons: {
                code: couponCode,
                usedAt: new Date(),
                plan: plan,
              },
            },
          },
          { upsert: true }
        );
      }

      await ActivityService.createLog({
        shop: session.shop,
        resourceType: "Billing",
        resourceId: plan,
        action: "Billing Confirmed",
        detail: `${plan} plan subscription confirmed and activated${couponCode ? ` with coupon ${couponCode}` : ''}`,
        status: "Success",
      });

      // Log subscription ID for tracking
      if (billingCheck.appSubscriptions?.[0]?.id) {
        console.log(`[Billing] Subscription activated: ${billingCheck.appSubscriptions[0].id} for ${session.shop}`);
      }
    }

    return redirect('/app/billing');
  } catch (error) {
    console.error("Error verifying billing:", error);
    return redirect('/app/billing');
  }
};

处理商家批准收费后的回调:
typescript
export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { session, billing } = await authenticate.admin(request);
  const url = new URL(request.url);
  const plan = url.searchParams.get('plan') as BillingPlans;
  const couponCode = url.searchParams.get('coupon')?.toUpperCase();

  try {
    // 验证订阅是否已激活
    const billingCheck = await billing.check({
      plans: [plan],
      isTest: process.env.NODE_ENV !== 'production',
    });

    if (billingCheck.hasActivePayment) {
      // 更新数据库中商家的套餐信息
      await shopService.updateApp(session.shop, APP_ID, {
        plan: plan,
        accessToken: session.accessToken,
      });

      // 记录优惠券使用情况
      if (couponCode) {
        await Settings.findOneAndUpdate(
          { shop: session.shop },
          {
            $push: {
              usedCoupons: {
                code: couponCode,
                usedAt: new Date(),
                plan: plan,
              },
            },
          },
          { upsert: true }
        );
      }

      await ActivityService.createLog({
        shop: session.shop,
        resourceType: "Billing",
        resourceId: plan,
        action: "Billing Confirmed",
        detail: `${plan} plan subscription confirmed and activated${couponCode ? ` with coupon ${couponCode}` : ''}`,
        status: "Success",
      });

      // 记录订阅ID用于追踪
      if (billingCheck.appSubscriptions?.[0]?.id) {
        console.log(`[Billing] Subscription activated: ${billingCheck.appSubscriptions[0].id} for ${session.shop}`);
      }
    }

    return redirect('/app/billing');
  } catch (error) {
    console.error("Error verifying billing:", error);
    return redirect('/app/billing');
  }
};

5. Check Subscription Status

5. 检查订阅状态

Using
billing.check()
(Non-blocking)

使用
billing.check()
(非阻塞)

typescript
export const loader = async ({ request }) => {
  const { billing, session } = await authenticate.admin(request);

  const billingCheck = await billing.check({
    plans: ["Basic", "Pro"],
    isTest: true,
  });

  if (billingCheck.hasActivePayment) {
    const subscription = billingCheck.appSubscriptions[0];
    // Access subscription details
  }

  return { hasActivePayment: billingCheck.hasActivePayment };
};
typescript
export const loader = async ({ request }) => {
  const { billing, session } = await authenticate.admin(request);

  const billingCheck = await billing.check({
    plans: ["Basic", "Pro"],
    isTest: true,
  });

  if (billingCheck.hasActivePayment) {
    const subscription = billingCheck.appSubscriptions[0];
    // 访问订阅详情
  }

  return { hasActivePayment: billingCheck.hasActivePayment };
};

Using
billing.require()
(Blocking)

使用
billing.require()
(阻塞)

typescript
export const loader = async ({ request }) => {
  const { billing, session } = await authenticate.admin(request);

  const billingCheck = await billing.require({
    plans: ["Basic", "Pro"],
    isTest: true,
    onFailure: async () => {
      // Redirect to billing page or show upgrade prompt
      return redirect('/app/billing?prompt=upgrade');
    },
  });

  // If we reach here, shop has active subscription
  const subscription = billingCheck.appSubscriptions[0];
  return { subscription };
};

typescript
export const loader = async ({ request }) => {
  const { billing, session } = await authenticate.admin(request);

  const billingCheck = await billing.require({
    plans: ["Basic", "Pro"],
    isTest: true,
    onFailure: async () => {
      // 重定向到计费页面或展示升级提示
      return redirect('/app/billing?prompt=upgrade');
    },
  });

  // 执行到此处说明商家有有效订阅
  const subscription = billingCheck.appSubscriptions[0];
  return { subscription };
};

6. Cancel Subscription

6. 取消订阅

typescript
export const action = async ({ request }) => {
  const { billing, session } = await authenticate.admin(request);
  const formData = await request.formData();
  const plan = formData.get("plan") as BillingPlans;

  if (request.method === 'DELETE') {
    try {
      const billingCheck = await billing.require({
        plans: [plan],
        onFailure: async () => {
          throw new Error('No plan active');
        },
      });

      const subscription = billingCheck.appSubscriptions[0];
      console.log(`[Billing] Cancelling subscription ${subscription.id} for ${session.shop}`);

      await billing.cancel({
        subscriptionId: subscription.id,
        isTest: process.env.NODE_ENV !== 'production',
        prorate: true, // Issue prorated credit for unused portion
      });

      // Update shop to free plan
      await shopService.updateApp(session.shop, APP_ID, {
        plan: "free",
      });

      await ActivityService.createLog({
        shop: session.shop,
        resourceType: "Billing",
        resourceId: plan,
        action: "Billing Cancelled",
        detail: `${plan} plan subscription cancelled`,
        status: "Success",
      });

      return redirect('/app/billing');
    } catch (error) {
      throw error;
    }
  }
};

typescript
export const action = async ({ request }) => {
  const { billing, session } = await authenticate.admin(request);
  const formData = await request.formData();
  const plan = formData.get("plan") as BillingPlans;

  if (request.method === 'DELETE') {
    try {
      const billingCheck = await billing.require({
        plans: [plan],
        onFailure: async () => {
          throw new Error('No plan active');
        },
      });

      const subscription = billingCheck.appSubscriptions[0];
      console.log(`[Billing] Cancelling subscription ${subscription.id} for ${session.shop}`);

      await billing.cancel({
        subscriptionId: subscription.id,
        isTest: process.env.NODE_ENV !== 'production',
        prorate: true, // 为未使用的周期提供按比例退款
      });

      // 将商家套餐更新为免费版
      await shopService.updateApp(session.shop, APP_ID, {
        plan: "free",
      });

      await ActivityService.createLog({
        shop: session.shop,
        resourceType: "Billing",
        resourceId: plan,
        action: "Billing Cancelled",
        detail: `${plan} plan subscription cancelled`,
        status: "Success",
      });

      return redirect('/app/billing');
    } catch (error) {
      throw error;
    }
  }
};

7. Billing UI Example (
app.billing.tsx
)

7. 计费UI示例(
app.billing.tsx

Key patterns for the billing interface:
typescript
export const loader = async ({ request }) => {
  const { session } = await authenticate.admin(request);

  // Get current usage and plan
  const [usage, plan, settings] = await Promise.all([
    UsageService.getCurrentUsage(session.shop),
    UsageService.getPlanType(session.shop),
    Settings.findOne({ shop: session.shop })
  ]);

  const planConfig = PLANS[plan] || PLANS.Free;
  const limits = planConfig.limits;

  return json({ usage, plan, limits });
};

// In your component:
// - Display usage progress bars
// - Show plan comparison with monthly/annual toggle
// - Include coupon input field
// - Handle upgrade/downgrade actions
Subscription URL Pattern:
typescript
// Build subscription URL with coupon
const subscriptionUrl = couponCode
  ? `/billing/subscription?plan=${planConfig.id}&coupon=${encodeURIComponent(couponCode)}`
  : `/billing/subscription?plan=${planConfig.id}`;

// Button links to subscription URL
<Button url={subscriptionUrl}>Upgrade</Button>

计费界面的核心实现模式:
typescript
export const loader = async ({ request }) => {
  const { session } = await authenticate.admin(request);

  // 获取当前使用量和套餐信息
  const [usage, plan, settings] = await Promise.all([
    UsageService.getCurrentUsage(session.shop),
    UsageService.getPlanType(session.shop),
    Settings.findOne({ shop: session.shop })
  ]);

  const planConfig = PLANS[plan] || PLANS.Free;
  const limits = planConfig.limits;

  return json({ usage, plan, limits });
};

// 在组件中:
// - 展示使用量进度条
// - 展示套餐对比,支持月付/年付切换
// - 包含优惠券输入框
// - 处理升级/降级操作
订阅URL模式:
typescript
// 构建带优惠券的订阅URL
const subscriptionUrl = couponCode
  ? `/billing/subscription?plan=${planConfig.id}&coupon=${encodeURIComponent(couponCode)}`
  : `/billing/subscription?plan=${planConfig.id}`;

// 按钮跳转到订阅URL
<Button url={subscriptionUrl}>升级</Button>

8. Billing Helper API Reference

8. 计费辅助工具API参考

billing.request(options)

billing.request(options)

Initiates a subscription charge. Returns a confirmation URL.
typescript
await billing.request({
  plan: string,                    // Plan ID from billingConfig
  isTest: boolean,                 // Enable test mode
  returnUrl: string,               // Merchant redirect after approval
  replacementBehavior?: 'STANDARD' | 'APPLY_IMMEDIATELY' | 'APPLY_ON_NEXT_BILLING_CYCLE',
  lineItems?: Array<{             // Optional: override for discounts
    interval: BillingInterval,
    discount?: {
      durationLimitInIntervals: number,
      value: {
        percentage: number,        // 0.5 = 50%
        amount?: { amount: number, currencyCode: string }
      }
    }
  }>
});
发起订阅收费请求,返回确认URL。
typescript
await billing.request({
  plan: string,                    // billingConfig中的套餐ID
  isTest: boolean,                 // 开启测试模式
  returnUrl: string,               // 商家批准后跳转的地址
  replacementBehavior?: 'STANDARD' | 'APPLY_IMMEDIATELY' | 'APPLY_ON_NEXT_BILLING_CYCLE',
  lineItems?: Array<{             // 可选:覆盖配置实现折扣
    interval: BillingInterval,
    discount?: {
      durationLimitInIntervals: number,
      value: {
        percentage: number,        // 0.5 = 5折
        amount?: { amount: number, currencyCode: string }
      }
    }
  }>
});

billing.check(options)

billing.check(options)

Checks if shop has active subscription (non-blocking).
typescript
const result = await billing.check({
  plans: string[],                 // Plan IDs to check
  isTest: boolean,
});

// Returns: { hasActivePayment: boolean, appSubscriptions: [...] }
检查商家是否有有效订阅(非阻塞)。
typescript
const result = await billing.check({
  plans: string[],                 // 要检查的套餐ID列表
  isTest: boolean,
});

// 返回:{ hasActivePayment: boolean, appSubscriptions: [...] }

billing.require(options)

billing.require(options)

Checks subscription and throws if not active (blocking).
typescript
const result = await billing.require({
  plans: string[],
  isTest: boolean,
  onFailure: async () => {
    // Called when no active subscription
    // Return redirect or throw error
  }
});
检查订阅状态,无效则抛出异常(阻塞)。
typescript
const result = await billing.require({
  plans: string[],
  isTest: boolean,
  onFailure: async () => {
    // 无有效订阅时调用
    // 返回重定向或抛出错误
  }
});

billing.cancel(options)

billing.cancel(options)

Cancels an active subscription.
typescript
await billing.cancel({
  subscriptionId: string,
  isTest: boolean,
  prorate: boolean,                // Credit merchant for unused time
});

取消有效订阅。
typescript
await billing.cancel({
  subscriptionId: string,
  isTest: boolean,
  prorate: boolean,                // 为商家未使用的周期退款
});

9. Replacement Behaviors

9. 替换行为

Control how new subscriptions interact with existing ones:
BehaviorDescription
STANDARD
Smart default: immediate for upgrades, deferred for downgrades
APPLY_IMMEDIATELY
Cancel current subscription immediately
APPLY_ON_NEXT_BILLING_CYCLE
Wait until current cycle ends
Example:
typescript
// For plan upgrades/downgrades
billingOptions.replacementBehavior = 'STANDARD';

// For immediate plan changes (e.g., merchant request)
billingOptions.replacementBehavior = 'APPLY_IMMEDIATELY';

// To avoid mid-cycle charges
billingOptions.replacementBehavior = 'APPLY_ON_NEXT_BILLING_CYCLE';

控制新订阅与现有订阅的交互逻辑:
行为描述
STANDARD
智能默认:升级立即生效,降级下一个周期生效
APPLY_IMMEDIATELY
立即取消当前订阅
APPLY_ON_NEXT_BILLING_CYCLE
等到当前周期结束后生效
示例:
typescript
// 套餐升级/降级使用
 billingOptions.replacementBehavior = 'STANDARD';

// 立即变更套餐(例如商家主动要求)
 billingOptions.replacementBehavior = 'APPLY_IMMEDIATELY';

// 避免周期中途收费
 billingOptions.replacementBehavior = 'APPLY_ON_NEXT_BILLING_CYCLE';

10. Webhooks

10. Webhooks

Subscribe to these webhook topics to monitor billing events:
Webhook TopicDescription
APP_SUBSCRIPTIONS_UPDATE
Subscription status changes (activated, cancelled, etc.)
APP_PURCHASES_ONE_TIME_UPDATE
One-time purchase status changes
APP_SUBSCRIPTIONS_APPROACHING_CAPPED_AMOUNT
Usage reaches 90% of cap
订阅以下Webhook主题来监控计费事件:
Webhook主题描述
APP_SUBSCRIPTIONS_UPDATE
订阅状态变更(激活、取消等)
APP_PURCHASES_ONE_TIME_UPDATE
一次性购买状态变更
APP_SUBSCRIPTIONS_APPROACHING_CAPPED_AMOUNT
使用量达到上限的90%

Webhook Handler Example

Webhook处理示例

typescript
// app/routes/webhooks.appSubscriptionsUpdate.tsx
export const action = async ({ request }) => {
  const { topic, shop, payload } = await authenticate.webhook(request);

  if (topic === "APP_SUBSCRIPTIONS_UPDATE") {
    const subscription = payload.appSubscription;

    if (subscription.status === "CANCELLED") {
      // Handle cancellation - revoke access
      await shopService.updateApp(shop, APP_ID, { plan: "free" });
    } else if (subscription.status === "ACTIVE") {
      // Grant access to features
      console.log(`Subscription activated for shop ${shop}`);
    }
  }

  return new Response(JSON.stringify({ success: true }));
};

typescript
// app/routes/webhooks.appSubscriptionsUpdate.tsx
export const action = async ({ request }) => {
  const { topic, shop, payload } = await authenticate.webhook(request);

  if (topic === "APP_SUBSCRIPTIONS_UPDATE") {
    const subscription = payload.appSubscription;

    if (subscription.status === "CANCELLED") {
      // 处理取消事件 - 收回权限
      await shopService.updateApp(shop, APP_ID, { plan: "free" });
    } else if (subscription.status === "ACTIVE") {
      // 开通功能权限
      console.log(`Subscription activated for shop ${shop}`);
    }
  }

  return new Response(JSON.stringify({ success: true }));
};

11. Best Practices

11. 最佳实践

Test Mode

测试模式

typescript
// Always use test mode in development
isTest: process.env.NODE_ENV !== 'production' || session.shop === process.env.SHOP_ADMIN
typescript
// 开发环境始终使用测试模式
isTest: process.env.NODE_ENV !== 'production' || session.shop === process.env.SHOP_ADMIN

Confirmation URL

确认URL

  • MUST redirect merchant to confirmation URL
  • Charge is NOT active until merchant approves
  • After approval, merchant redirects to your
    returnUrl
  • 必须将商家重定向到确认URL
  • 商家批准之前收费不会生效
  • 批准后商家会跳转到你设置的
    returnUrl

Coupon System

优惠券系统

  • Store used coupons in database for one-time use
  • Check usage before applying discount
  • Log coupon usage for analytics
  • 将已使用的优惠券存储在数据库中实现单次使用限制
  • 应用折扣前检查使用情况
  • 记录优惠券使用情况用于数据分析

Subscription Management

订阅管理

  • An app can have only one active subscription per merchant
  • Creating a new subscription replaces the existing one
  • Use
    replacementBehavior
    to control timing
  • Handle
    APP_SUBSCRIPTIONS_UPDATE
    webhook for cancellations
  • 每个商家在一个应用中只能有一个有效订阅
  • 创建新订阅会替换现有订阅
  • 使用
    replacementBehavior
    控制生效时间
  • 处理
    APP_SUBSCRIPTIONS_UPDATE
    webhook实现取消逻辑

Proration

按比例退款

typescript
// Always prorate when cancelling for better UX
await billing.cancel({
  subscriptionId: subscription.id,
  prorate: true,  // Issue credit for unused time
});
typescript
// 取消订阅时始终提供按比例退款提升用户体验
await billing.cancel({
  subscriptionId: subscription.id,
  prorate: true,  // 为未使用的时间退款
});

Error Handling

错误处理

  • Always check
    userErrors
    in mutation responses
  • Handle declined charges gracefully
  • Provide clear messaging about billing status
  • Log all billing events for debugging

  • 始终检查mutation响应中的
    userErrors
  • 优雅处理支付失败的情况
  • 提供清晰的计费状态提示
  • 记录所有计费事件用于调试

12. Common Patterns

12. 常见实现模式

Plan Upgrade with Discount

带折扣的套餐升级

typescript
// /billing/subscription?plan=Pro&coupon=WELCOME30
// → 30% off for 2 billing cycles
typescript
// /billing/subscription?plan=Pro&coupon=WELCOME30
// → 连续2个计费周期享受7折优惠

Switch Billing Cycle

切换计费周期

typescript
// From Monthly to Annual
billing.request({
  plan: "ProAnnual",
  replacementBehavior: 'STANDARD',  // Shopify will handle timing
});
typescript
// 从月付切换到年付
billing.request({
  plan: "ProAnnual",
  replacementBehavior: 'STANDARD',  // Shopify会自动处理生效时间
});

Downgrade to Free

降级到免费版

typescript
// Cancel paid subscription
await billing.cancel({
  subscriptionId: subscription.id,
  prorate: true,
});
// Then update shop to free plan

typescript
// 取消付费订阅
await billing.cancel({
  subscriptionId: subscription.id,
  prorate: true,
});
// 然后将商家套餐更新为免费版

13. Project-Specific Notes

13. 项目特定说明

  • Trial Days: All paid plans get 7-day free trial
  • Test Mode: Automatically enabled in non-production
  • Proration: Enabled for all cancellations
  • Coupon Storage:
    Settings.usedCoupons
    array
  • Plan Storage:
    shops.app[].plan
    field
  • VIP Status: Separate system for gifted plans

  • 试用天数:所有付费套餐都有7天免费试用
  • 测试模式:非生产环境自动开启
  • 按比例退款:所有取消操作都开启
  • 优惠券存储:存储在
    Settings.usedCoupons
    数组中
  • 套餐存储:存储在
    shops.app[].plan
    字段中
  • VIP状态:赠送套餐使用独立系统

Resources

参考资源