如何为在线软件接入信用卡定期付款与多币种支付?
结论
这类需求可以选择支持订阅、Webhook 和多币种结算的支付网关。大多数在线软件或 SaaS 可以优先考虑 Stripe Billing。业务覆盖国家较多、支付方式复杂或交易规模较大时,可以评估 Adyen。如果已经在使用 PayPal 生态,也可以考虑 Braintree。
如果希望平台代为处理销售税、VAT、发票及部分合规事务,可以评估 Paddle 等 Merchant of Record 服务。这类服务与普通 payment gateway 的法律关系和结算模式不同,接入前需要确认费率、退款规则以及商户主体关系。
无论选择哪家服务商,都不建议让信用卡号经过自己的服务器。更合适的做法是在软件界面中嵌入服务商提供的安全组件,由服务商直接收集卡片信息,再返回 token、PaymentMethod ID 等非敏感标识。这样既能保留站内付款体验,也能大幅缩小 PCI DSS 合规范围。
推荐的实现方式
典型的支付流程如下:
- 客户端加载支付服务商提供的 SDK 或托管支付组件。
- 用户在组件中填写信用卡信息。
- 卡片数据直接发送给支付服务商,不经过业务服务器。
- 业务服务器创建客户、价格和订阅。
- 支付服务商按账期自动扣款。
- 扣款成功、失败、退款或订阅状态发生变化时,支付服务商调用 Webhook。
- 软件验证 Webhook 签名,并更新本地订阅权限。
不要仅凭用户支付后跳转到了”成功页面”来判断付款结果。用户可能关闭页面,网络也可能中断。经过签名验证的 Webhook 才是可信的结果来源。
使用 Stripe Billing 的示例流程
下面使用 Node.js 和 Express 展示核心逻辑。代码仅用于说明结构,实际字段应以接入时使用的 Stripe SDK 和官方 API 文档为准。
1. 为不同币种配置价格
订阅价格通常绑定一种币种。可以为同一个套餐分别建立 USD、EUR、GBP 等价格,并在数据库中保存允许使用的 price ID:
const PRICE_MAP = {
basic: {
usd: "price_basic_usd",
eur: "price_basic_eur",
gbp: "price_basic_gbp"
},
pro: {
usd: "price_pro_usd",
eur: "price_pro_eur",
gbp: "price_pro_gbp"
}
};
不要接受客户端直接提交的任意 price ID 或金额,否则用户可能篡改请求。客户端只需提交套餐和币种,服务器再从白名单中选择对应价格。
2. 在服务器创建订阅
import express from "express";
import Stripe from "stripe";
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const PRICE_MAP = {
basic: {
usd: "price_basic_usd",
eur: "price_basic_eur"
}
};
app.use("/api", express.json());
app.post("/api/subscriptions", async (req, res) => {
try {
const { customerId, paymentMethodId, plan, currency } = req.body;
const normalizedCurrency = String(currency).toLowerCase();
const priceId = PRICE_MAP[plan]?.[normalizedCurrency];
if (!priceId) {
return res.status(400).json({
error: "Unsupported plan or currency"
});
}
await stripe.paymentMethods.attach(paymentMethodId, {
customer: customerId
});
await stripe.customers.update(customerId, {
invoice_settings: {
default_payment_method: paymentMethodId
}
});
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
payment_behavior: "default_incomplete",
expand: ["latest_invoice.payment_intent"],
metadata: {
internalPlan: plan
}
});
const paymentIntent =
subscription.latest_invoice?.payment_intent;
res.json({
subscriptionId: subscription.id,
status: subscription.status,
clientSecret: paymentIntent?.client_secret ?? null
});
} catch (error) {
res.status(400).json({
error: error instanceof Error ? error.message : "Payment error"
});
}
});
首次付款可能需要进行 3-D Secure 或其他 Strong Customer Authentication 验证。客户端应使用返回的 clientSecret 完成后续确认,不能假定订阅创建成功就代表付款成功。
卡片输入应使用 Stripe Elements、Payment Element 或 Checkout 等官方组件。不要自行制作普通 <input> 来收集卡号,再把数据提交给业务服务器。
3. 接收定期扣款通知
Webhook 路由通常需要读取原始请求体,否则可能无法正确验证签名:
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["stripe-signature"];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch {
return res.status(400).send("Invalid webhook signature");
}
switch (event.type) {
case "invoice.paid": {
const invoice = event.data.object;
// 根据 customer 或 subscription 查找本地账户
// 将对应账期标记为已付款
await markInvoicePaid({
providerEventId: event.id,
invoiceId: invoice.id,
customerId: invoice.customer,
subscriptionId: invoice.subscription
});
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object;
await markPaymentFailed({
providerEventId: event.id,
invoiceId: invoice.id,
customerId: invoice.customer,
subscriptionId: invoice.subscription
});
break;
}
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const subscription = event.data.object;
await syncSubscriptionStatus({
providerEventId: event.id,
subscriptionId: subscription.id,
status: subscription.status
});
break;
}
}
res.sendStatus(200);
}
);
markInvoicePaid 等函数应使用数据库事务,并为 event.id 建立唯一约束。支付平台可能重复投递同一个事件,也不保证不同类型的事件会严格按照业务顺序到达,因此 Webhook 处理必须具备幂等性。
不要在 Webhook 中执行耗时任务。可以先验证签名、保存事件并返回 2xx,再通过任务队列异步开通权限、发送邮件或生成发票。
多币种支付需要区分三个概念
“支持多币种”不只是让界面显示不同货币,还要分别确认:
- 呈现币种:用户看到并实际支付的币种。
- 定价币种:套餐采用的计价币种,例如
USD 19或EUR 17。 - 结算币种:支付服务商最终向商户银行账户打款时使用的币种。
即使支付服务商允许用户用 EUR 付款,也不代表服务商一定能将 EUR 原币打入商户账户。服务商可能先按照自己的汇率转换为商户的结算币种,并收取换汇费用。
通常应该为每个套餐设置明确的币种价格,而不是在扣款时自行按照实时汇率换算。例如:
Basic / USD / 19.00 per month
Basic / EUR / 17.00 per month
Basic / GBP / 15.00 per month
固定价格更稳定,也方便处理退款、财务核对和续费通知。还需要提前规定用户更改币种时如何处理,例如立即切换订阅、下个账期生效,或者取消旧订阅后重新创建。不要直接修改正在结算的账单金额。
如何选择支付服务商
可以从以下方面进行比较:
| 需求 | 重点核对内容 |
|---|---|
| 定期付款 | 是否提供原生订阅、试用期、升级降级、按比例计费和自动重试 |
| 结果通知 | 是否提供签名 Webhook、事件重试和事件查询 |
| 多币种 | 支付币种、结算币种、换汇规则及不同币种的最低金额 |
| 卡片安全 | 是否有 Hosted Fields、Elements 或托管结账页 |
| 身份验证 | 是否支持 3-D Secure、SCA 等当地要求 |
| 支付方式 | 是否还需要 Apple Pay、Google Pay、SEPA 等方式 |
| 财务能力 | 发票、退款、争议处理、对账和收入确认 |
| 商户条件 | 是否支持公司的注册国家、行业和银行账户 |
| 成本 | 交易费、订阅服务费、跨境费、换汇费和拒付费 |
对于希望快速接入 SaaS 订阅,并自行控制产品和税务流程的团队,Stripe 通常比较合适。Adyen 更适合支付方式多、覆盖地区广、交易规模较大的业务。Paddle 则适合希望由第三方作为记录商户,负责付款及部分税务工作的软件厂商,但它的业务控制方式和成本结构也有所不同。
服务商支持的国家、币种、费率和产品能力可能调整。最终选择前,应结合公司的注册地、用户所在地、预计交易量和结算账户,核对各服务商当前的官方支持范围。
容易忽略的事项
- 绝不能在日志、数据库或错误追踪系统中记录完整卡号、CVC 或客户端密钥。
- Secret Key 和 Webhook Secret 只能保存在服务器端的密钥管理系统或环境变量中。
- Webhook 必须验证签名,并防止重复处理。
- 软件权限应由订阅和账单状态决定,不能只看客户端返回的结果。
- 扣款失败后通常需要设置宽限期和重试策略,不宜在收到一次失败通知后立即删除客户数据。
- 需要处理退款、拒付、订阅取消、试用结束和付款方式过期等事件。
- 测试环境与生产环境的 API 密钥、价格 ID、客户 ID 和 Webhook 地址必须彻底隔离。
- 上线前需要完成退款条款、隐私政策、服务条款,以及适用地区的税务和消费者保护合规检查。
对于标准 SaaS 场景,使用 Stripe Billing、支付组件、服务端订阅接口和签名 Webhook,通常可以满足信用卡定期付款、付款结果通知和多币种支付的需求。最终如何选择,主要取决于商户注册地、目标市场、税务承担方式,以及团队希望保留多少支付流程的控制权。
备注:内容仅供参考。
