shopify-billing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseShopify 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:
- Time-based subscriptions - Recurring charges at set intervals (30 days or annual)
- Usage-based subscriptions - Charges based on app usage during 30-day cycles
- One-time purchases - Single charges for features or services
Shopify支持三种计费模式:
- 基于时间的订阅 - 按固定周期(30天或按年)定期收费
- 按使用量订阅 - 按30天周期内的应用使用量收费
- 一次性购买 - 为功能或服务收取单次费用
Project Implementation Pattern
项目实现模式
This project uses (v3.8+) which provides a simplified billing helper. Here's how billing is structured:
@shopify/shopify-app-remixapp/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本项目使用(v3.8及以上版本),它提供了简化的计费辅助工具。计费模块的结构如下:
@shopify/shopify-app-remixapp/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
)
app/config/plans.ts1. 套餐配置(app/config/plans.ts
)
app/config/plans.tsDefine 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
shopify.server.ts2. shopify.server.ts
中的计费配置
shopify.server.tsGenerate 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
)
billing.subscription.tsx3. 发起订阅(billing.subscription.tsx
)
billing.subscription.tsxCreate 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
)
billing.subscription-confirm.tsx4. 确认订阅(billing.subscription-confirm.tsx
)
billing.subscription-confirm.tsxHandle 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()使用billing.check()
(非阻塞)
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()使用billing.require()
(阻塞)
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
)
app.billing.tsx7. 计费UI示例(app.billing.tsx
)
app.billing.tsxKey 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 actionsSubscription 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)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)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)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)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:
| Behavior | Description |
|---|---|
| Smart default: immediate for upgrades, deferred for downgrades |
| Cancel current subscription immediately |
| 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';控制新订阅与现有订阅的交互逻辑:
| 行为 | 描述 |
|---|---|
| 智能默认:升级立即生效,降级下一个周期生效 |
| 立即取消当前订阅 |
| 等到当前周期结束后生效 |
示例:
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 Topic | Description |
|---|---|
| Subscription status changes (activated, cancelled, etc.) |
| One-time purchase status changes |
| Usage reaches 90% of cap |
订阅以下Webhook主题来监控计费事件:
| Webhook主题 | 描述 |
|---|---|
| 订阅状态变更(激活、取消等) |
| 一次性购买状态变更 |
| 使用量达到上限的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_ADMINtypescript
// 开发环境始终使用测试模式
isTest: process.env.NODE_ENV !== 'production' || session.shop === process.env.SHOP_ADMINConfirmation 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 to control timing
replacementBehavior - Handle webhook for cancellations
APP_SUBSCRIPTIONS_UPDATE
- 每个商家在一个应用中只能有一个有效订阅
- 创建新订阅会替换现有订阅
- 使用控制生效时间
replacementBehavior - 处理webhook实现取消逻辑
APP_SUBSCRIPTIONS_UPDATE
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 in mutation responses
userErrors - 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 cyclestypescript
// /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 plantypescript
// 取消付费订阅
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: array
Settings.usedCoupons - Plan Storage: field
shops.app[].plan - VIP Status: Separate system for gifted plans
- 试用天数:所有付费套餐都有7天免费试用
- 测试模式:非生产环境自动开启
- 按比例退款:所有取消操作都开启
- 优惠券存储:存储在数组中
Settings.usedCoupons - 套餐存储:存储在字段中
shops.app[].plan - VIP状态:赠送套餐使用独立系统