PAYATHON 2026

.NET 网站如何实现信用卡月度订阅付款?

支付阿杰

结论

可以实现,但不要自行保存银行卡信息,也不要编写定时任务直接扣款。更稳妥的做法是接入支持订阅计费的支付服务,例如 Stripe Billing、PayPal Subscriptions,或当地收单机构提供的周期扣款产品。

常见流程如下:

  1. 在支付平台创建按月计费的价格。
  2. 使用平台托管的支付页面收集银行卡信息。
  3. 由支付平台创建订阅并按月自动扣款。
  4. .NET 后端通过 Webhook 同步付款成功、付款失败、取消等状态。
  5. 用户取消订阅时,调用支付平台的取消订阅 API,停止后续扣款。

下面使用 ASP.NET Core 和 Stripe 举例。Stripe.NET 的具体类名和参数可能随版本变化,实施时应以已安装版本的官方文档为准。

为什么要交给支付平台处理

银行卡号、有效期和安全码都是敏感支付数据。如果网站服务器直接接收或保存这些信息,PCI DSS 合规范围会明显扩大,数据泄露风险也会增加。

使用支付平台托管的 Checkout 页面或官方前端组件后,银行卡数据可以直接提交给支付平台。应用数据库通常只需保存:

  • 本地用户 ID
  • 支付平台客户 ID
  • 订阅 ID
  • 价格或套餐 ID
  • 订阅状态
  • 当前计费周期结束时间

不要在日志或数据库中保存完整卡号、CVV/CVC。即使业务方提出这类要求,也不应采用这样的设计。

基本数据模型

可以为用户添加以下订阅字段:

public sealed class UserSubscription
{
    public long Id { get; set; }
    public string UserId { get; set; } = default!;

    public string ProviderCustomerId { get; set; } = default!;
    public string ProviderSubscriptionId { get; set; } = default!;
    public string PriceId { get; set; } = default!;

    public string Status { get; set; } = default!;
    public DateTimeOffset? CurrentPeriodEnd { get; set; }
}

Status 不应简化为 bool IsPaid。订阅可能处于试用、正常、欠款、暂停、已取消或未完成等状态,因此需要保留支付平台返回的实际状态。

创建月度订阅

先在支付平台后台创建产品和月度价格,取得类似 price_xxx 的价格 ID。价格和金额最好由服务器根据套餐读取,不要直接使用浏览器传入的金额。

安装 Stripe.NET:

dotnet add package Stripe.net

密钥应保存在配置中。生产环境要使用密钥管理服务或安全的环境变量,不要把密钥提交到 Git 仓库。

{
  "Stripe": {
    "SecretKey": "从安全配置源读取",
    "WebhookSecret": "从安全配置源读取"
  }
}

创建 Checkout Session:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Stripe.Checkout;

[ApiController]
[Route("api/subscriptions")]
public sealed class SubscriptionsController : ControllerBase
{
    [Authorize]
    [HttpPost("checkout")]
    public ActionResult CreateCheckout()
    {
        var userId = User.FindFirst("sub")?.Value;

        if (string.IsNullOrWhiteSpace(userId))
            return Unauthorized();

        // 应由服务端根据用户选择的套餐查询,不要直接信任客户端 PriceId。
        const string priceId = "price_xxx";

        var options = new SessionCreateOptions
        {
            Mode = "subscription",
            SuccessUrl = "https://example.com/account?checkout=success",
            CancelUrl = "https://example.com/pricing?checkout=cancelled",
            ClientReferenceId = userId,
            LineItems = new List<SessionLineItemOptions>
            {
                new()
                {
                    Price = priceId,
                    Quantity = 1
                }
            },
            SubscriptionData = new SessionSubscriptionDataOptions
            {
                Metadata = new Dictionary<string, string>
                {
                    ["userId"] = userId
                }
            }
        };

        var service = new SessionService();
        var session = service.Create(options);

        return Ok(new { session.Url });
    }
}

服务端创建 Session 后,前端跳转到 session.Url。银行卡信息由用户在支付平台页面输入,不会经过网站后端。

不能仅凭用户访问了 SuccessUrl 就开通服务。这个请求可能被伪造,用户也可能在付款完成前关闭页面。最终状态必须通过经过签名验证的 Webhook 确认。

通过 Webhook 同步订阅状态

订阅创建、扣款成功、扣款失败或订阅取消时,支付平台会向网站发送事件。常见事件包括:

  • checkout.session.completed
  • invoice.paid
  • invoice.payment_failed
  • customer.subscription.updated
  • customer.subscription.deleted

Webhook 端点需要读取原始请求体并验证签名:

using Microsoft.AspNetCore.Mvc;
using Stripe;

[ApiController]
[Route("api/webhooks/stripe")]
public sealed class StripeWebhookController : ControllerBase
{
    private readonly IConfiguration _configuration;

    public StripeWebhookController(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    [HttpPost]
    public async Task<IActionResult> Handle()
    {
        var json = await new StreamReader(Request.Body)
            .ReadToEndAsync();

        var signature = Request.Headers["Stripe-Signature"].ToString();
        var webhookSecret =
            _configuration["Stripe:WebhookSecret"]
            ?? throw new InvalidOperationException("Webhook secret is missing.");

        Event stripeEvent;

        try
        {
            stripeEvent = EventUtility.ConstructEvent(
                json,
                signature,
                webhookSecret);
        }
        catch (StripeException)
        {
            return BadRequest();
        }

        switch (stripeEvent.Type)
        {
            case "invoice.paid":
                // 将对应账期标记为已付款,必要时恢复服务。
                break;

            case "invoice.payment_failed":
                // 标记付款失败,通知用户更新付款方式。
                break;

            case "customer.subscription.updated":
                // 同步订阅状态和计费周期结束时间。
                break;

            case "customer.subscription.deleted":
                // 标记订阅已取消,不再提供新的付费周期。
                break;
        }

        return Ok();
    }
}

实际项目还要记录支付平台的事件 ID,并为该字段建立唯一约束。支付平台可能重复投递同一个事件,因此 Webhook 处理必须具备幂等性。

Webhook 的到达顺序不一定与事件的发生顺序完全一致。更新状态时,要结合事件创建时间、订阅 ID 和数据库中的当前状态进行判断,防止旧事件覆盖新状态。

取消订阅

取消通常有两种处理方式:

  • 立即取消:现在停止订阅,不再产生后续账单。
  • 周期结束时取消:不再续费,但允许用户继续使用到已付款周期结束。

如果要求取消后立即停止后续扣款,可以直接删除支付平台上的订阅:

using Stripe;

public sealed class SubscriptionManager
{
    public async Task CancelImmediatelyAsync(
        string providerSubscriptionId,
        CancellationToken cancellationToken)
    {
        var service = new SubscriptionService();

        await service.CancelAsync(
            providerSubscriptionId,
            cancellationToken: cancellationToken);
    }
}

控制器不能接收任意订阅 ID 后直接执行取消。应先从数据库查询当前登录用户的订阅,并确认该订阅确实属于此用户:

[Authorize]
[HttpPost("cancel")]
public async Task<IActionResult> Cancel(
    CancellationToken cancellationToken)
{
    var userId = User.FindFirst("sub")?.Value;

    if (string.IsNullOrWhiteSpace(userId))
        return Unauthorized();

    var subscription = await FindSubscriptionForUserAsync(
        userId,
        cancellationToken);

    if (subscription is null)
        return NotFound();

    await _subscriptionManager.CancelImmediatelyAsync(
        subscription.ProviderSubscriptionId,
        cancellationToken);

    return Accepted();
}

Accepted 表示取消请求已经提交。数据库中的最终状态仍要通过 Webhook 更新,不能只依赖这次 API 调用。

如果业务允许用户继续使用到当前付款周期结束,可以设置 CancelAtPeriodEnd = true,不必立即取消:

var service = new SubscriptionService();

await service.UpdateAsync(
    providerSubscriptionId,
    new SubscriptionUpdateOptions
    {
        CancelAtPeriodEnd = true
    },
    cancellationToken: cancellationToken);

“停止后续扣款”和”立即关闭访问权限”是两个不同的问题。立即取消通常会阻止下一次续费,但是否退款、是否立刻关闭服务,需要由业务规则明确规定。

自动扣款失败怎么处理

信用卡可能因为余额不足、卡片过期、银行拒绝或需要额外验证而扣款失败。一次失败不应直接视为永久取消。

可以按以下方式处理:

  1. 记录失败状态,但不要保存敏感卡片信息。
  2. 通过邮件或站内通知提醒用户更新付款方式。
  3. 使用支付平台提供的重试和催收设置。
  4. 设置宽限期,例如在若干天内保留有限的访问权限。
  5. 最终扣款失败或订阅正式取消后,再关闭服务。

具体的重试次数和间隔通常可以在支付平台后台配置,不建议自行编写扣款重试定时器。

上线前需要确认的事项

  • 使用 HTTPS,分别管理测试环境和生产环境的密钥。
  • 由服务器控制价格、币种和套餐映射。
  • 验证所有 Webhook 签名。
  • Webhook、创建订阅和取消操作都要具备幂等性。
  • 保存支付平台的客户 ID、订阅 ID 和事件 ID。
  • 明确取消、退款、试用期、欠款和套餐变更规则。
  • 不保存完整卡号或安全码。
  • 确认服务商是否支持目标国家、币种、银行卡类型和周期扣款。
  • 根据经营地区处理税费、发票、消费者撤销权和自动续费提示。
  • 在测试环境中验证首次付款、续费成功、续费失败、换卡、重复事件和取消等完整流程。

基本分工是,支付平台负责银行卡数据和周期扣款,.NET 应用负责用户、套餐、权限以及订阅状态同步。这样可以实现按月自动收费和随时取消,同时降低支付安全与合规风险。

备注:内容仅供参考。