支付

目前 ShipAny 支持 Stripe 网页支付功能。其他支付渠道逐步对接。

配置 Stripe 支付

  1. 请确保在 ShipAny 配置 Stripe 支付前,你已经开通了 Stripe 商户。

  2. 在 Stripe 开发者控制台,创建支付密钥。

如果是本地调试,你可以选择开启 test mode,获取一对测试密钥。

stripe-keys

  1. 修改 ShipAny 项目配置,开启 Stripe 支付。

配置环境变量,根据自己项目的需求修改支付成功 / 支付失败 / 支付取消的回调地址。

.env.development
STRIPE_PUBLIC_KEY = "pk_test_xxx"
STRIPE_PRIVATE_KEY = "sk_test_xxx"

NEXT_PUBLIC_PAY_SUCCESS_URL = "http://localhost:3000/my-orders"
NEXT_PUBLIC_PAY_FAIL_URL = "http://localhost:3000/#pricing"
NEXT_PUBLIC_PAY_CANCEL_URL = "http://localhost:3000/#pricing"
  1. 创建订单表

创建订单表之前,请确保你已经参考:数据库 一章的步骤,配置好了数据库存储和连接信息。

并且执行 sql 创建了 users 用户信息表。

复制 data/install.sql 文件里的 orders 表的建表语句,在你的数据库里面创建订单信息表。

data/install.sql
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    order_no VARCHAR(255) UNIQUE NOT NULL,
    created_at timestamptz,
    user_uuid VARCHAR(255) NOT NULL DEFAULT '',
    user_email VARCHAR(255) NOT NULL DEFAULT '',
    amount INT NOT NULL,
    interval VARCHAR(50),
    expired_at timestamptz,
    status VARCHAR(50) NOT NULL,
    stripe_session_id VARCHAR(255),
    credits INT NOT NULL,
    currency VARCHAR(50),
    sub_id VARCHAR(255),
    sub_interval_count int,
    sub_cycle_anchor int,
    sub_period_end int,
    sub_period_start int,
    sub_times int,
    product_id VARCHAR(255),
    product_name VARCHAR(255),
    valid_months int,
    order_detail TEXT,
    paid_at timestamptz,
    paid_email VARCHAR(255),
    paid_detail TEXT
);
  1. 配置价格表

ShipAny 内置了一个价格表组件(Pricing):

components/blocks/pricing/index.tsx

通过配置数据,展示价格表,默认支持多语言。

比如默认的英文价格表配置位于:

i18n/pages/landing/en.json

你可以根据自己的需求,修改 pricing 字段下的价格表信息。

pricing-table

  1. 预览价格表

配置完成后,打开网站首页,可以看到配置好的价格表。

pricing-preview

  1. 测试支付

点击价格表的下单按钮,跳转到支付控制台。

如果是测试环境,可以在 Stripe 测试卡 页面,复制一张测试卡号,

填写到 Stripe 支付表单,进行支付测试。

stripe-checkout

  1. 处理支付结果

支付成功后,默认跳转到 /pay-success/xxx 页面,同步处理支付回调。

app/[locale]/pay-success/[session_id]/page.tsx
import Stripe from "stripe";
import { handleOrderSession } from "@/services/order";
import { redirect } from "next/navigation";
 
export const runtime = "edge";
 
export default async function ({ params }: { params: { session_id: string } }) {
  try {
    const stripe = new Stripe(process.env.STRIPE_PRIVATE_KEY || "");
    const session = await stripe.checkout.sessions.retrieve(params.session_id);
 
    await handleOrderSession(session);
 
    redirect(process.env.NEXT_PUBLIC_PAY_SUCCESS_URL || "/");
  } catch (e) {
    redirect(process.env.NEXT_PUBLIC_PAY_FAIL_URL || "/");
  }
}

更新完订单状态后,再跳转到配置文件中设置的 NEXT_PUBLIC_PAY_SUCCESS_URL 地址。

默认情况下,支付成功后的处理逻辑,只会更新订单的状态和支付信息。

你可以修改这里的逻辑,加上你自己的业务逻辑。比如发邮件 / 发通知 / 加积分等。

services/order.ts
import { findOrderByOrderNo, updateOrderStatus } from "@/models/order";
 
import Stripe from "stripe";
import { getIsoTimestr } from "@/lib/time";
 
export async function handleOrderSession(session: Stripe.Checkout.Session) {
  try {
    if (
      !session ||
      !session.metadata ||
      !session.metadata.order_no ||
      session.payment_status !== "paid"
    ) {
      throw new Error("invalid session");
    }
 
    const order_no = session.metadata.order_no;
    const paid_email =
      session.customer_details?.email || session.customer_email || "";
    const paid_detail = JSON.stringify(session);
 
    const order = await findOrderByOrderNo(order_no);
    if (!order || order.status !== "created") {
      throw new Error("invalid order");
    }
 
    const paid_at = getIsoTimestr();
    await updateOrderStatus(order_no, "paid", paid_at, paid_email, paid_detail);
 
    console.log(
      "handle order session successed: ",
      order_no,
      paid_at,
      paid_email,
      paid_detail
    );
  } catch (e) {
    console.log("handle order session failed: ", e);
    throw e;
  }
}

支付结果异步通知

同步处理支付结果是不可靠的,可能出现的情况是在跳转过程中,用户误操作关闭了浏览器页面,导致更新订单状态和支付信息的逻辑没办法执行。用户后续看到的,还是订单未支付成功状态,引起争议。

项目正式上线之前,建议配置支付异步通知。

  1. Stripe 后台配置 Webhook

参考文档:配置 Webhook 接收 Stripe 事件

本地调试,通过 stripe cli 监听事件:

Terminal
stripe listen --events checkout.session.completed,invoice.payment_succeeded --forward-to localhost:3000/api/stripe-notify

把用户支付后的 Stripe 回调事件,转发到本地的 ShipAny 服务的 /api/stripe-notify 接口。

  1. 修改配置文件

上一步在本地监听成功后,会得到一个 webhook signing secret,把这个参数的值填写到 ShipAny 项目的配置文件中:

.env.development
STRIPE_WEBHOOK_SECRET = "whsec_cexxx"
  1. 支付回调测试

在之前配置的价格表页面下单,支付成功后。本地监听的服务会收到回调通知。

stripe-cli

  1. 处理支付回调结果

你可以按照自己的实际需求,修改默认的支付回调处理逻辑:

app/api/stripe-notify/route.ts
import Stripe from "stripe";
import { handleOrderSession } from "@/services/order";
import { respOk } from "@/lib/resp";
 
export const runtime = "edge";
 
export async function POST(req: Request) {
  try {
    const stripePrivateKey = process.env.STRIPE_PRIVATE_KEY;
    const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
 
    if (!stripePrivateKey || !stripeWebhookSecret) {
      throw new Error("invalid stripe config");
    }
 
    const stripe = new Stripe(stripePrivateKey);
 
    const sign = req.headers.get("stripe-signature") as string;
    const body = await req.text();
    if (!sign || !body) {
      throw new Error("invalid notify data");
    }
 
    const event = await stripe.webhooks.constructEventAsync(
      body,
      sign,
      stripeWebhookSecret
    );
 
    console.log("stripe notify event: ", event);
 
    switch (event.type) {
      case "checkout.session.completed": {
        const session = event.data.object;
 
        await handleOrderSession(session);
        break;
      }
 
      default:
        console.log("not handle event: ", event.type);
    }
 
    return respOk();
  } catch (e: any) {
    console.log("stripe notify failed: ", e);
    return Response.json(
      { error: `handle stripe notify failed: ${e.message}` },
      { status: 500 }
    );
  }
}
  1. 在上线到生产环境之前,你需要在 Stripe 生产环境配置支付回调 Webhook。

stripe-notify

支付定制化

  1. 分组价格表

修改价格表配置文件,添加 groups 数据,配置多个价格分组。并在 pricing.items 下为每一个价格方案,设置一个 group 名称。

pricing-group

配置完成后,点击价格分组,将根据 group 字段,切换显示不同的价格方案。

pricing-group-preview

  1. 订阅支付

ShipAny 默认支持三种支付方案

  • 一次性扣费:one-time
  • 按月订阅扣费:month
  • 按年订阅扣费:year

你只需要修改价格表配置,把每个价格方案的 interval 字段,设置成上述三个值之一。

同时,按需修改价格(amount) / 积分(credits) / 有效期(valid_months) 等字段。

举例:按月订阅扣费,月付 $99,购买后得到 30 个积分,有效期 1 个月,则核心的价格表配置信息为:

"interval": "month",
"amount": 9900,
"currency": "USD",
"credits": 30,
"valid_months": 1
  1. 设置折扣码

先在 Stripe 后台,创建折扣码

再修改 app/api/checkout/route.ts 文件中的 options 参数。

  • 允许用户输入自定义的折扣码
options.allow_promotion_codes = true;
  • 使用系统默认的折扣码
options.allow_promotion_codes = false;
options.discounts = [
  {
    coupon: "HAPPY-NEW-YEAR",
  },
];
  • 不使用折扣
options.allow_promotion_codes = false;
options.discounts = [];
  1. 人民币支付

首先,在 Stripe 后台,设置支付方式

开通支付宝和微信支付。

然后,修改价格表配置文件,在每个价格方案下添加一个 cn_amount 字段,即可支持人民币支付。

比如,产品售价,$99,人民币支付价格为 699 元,核心配置信息为:

"amount": 9900,
"cn_amount": 69900,
"currency": "USD"

配置完成后,在价格表下单按钮上方,将会显示一个人民币支付图标。

ShipAny 未能适配所有的支付场景。请根据你的实际业务需求,自行修改

  • 价格表组件:components/blocks/pricing/index.tsx
  • 支付下单接口:app/api/checkout/route.ts
  • 支付回调逻辑:app/api/stripe-notify/route.ts

参考