@wecode-team/payment-paypal
v0.0.1
Published
We0 PayPal SDK —— 自定义订单支付,服务端下单与验签
Readme
@wecode-team/payment-paypal 集成指南
当用户选择「PayPal 支付」功能时,在当前 Razzle / Express 项目中集成 @wecode-team/payment-paypal SDK。
SDK 自动从 Supabase 配置表读取 PayPal 凭证,业务侧只需传 supabase 实例。
1. 安装依赖
npm install @wecode-team/payment-paypalpackage.json 的 dependencies 中确保:
"@wecode-team/payment-paypal": "latest"运行环境:Node.js 18+
2. 核心参数
2.1 createPayPalClient — 创建客户端
唯一必填参数:supabase 实例。
const client = await createPayPalClient({ supabase });强约束(必须遵守):
- 只传
supabase,禁止传config参数覆盖读取规则。配置读取策略由 SDK 内部管理(设置PROJECT_ID时自动按 CMS 配置表约定读取,否则用通用config表)。 supabase必须使用 service role key 创建,不能用 anon key(anon key 无法读取配置表)。- 禁止手动拼接 PayPal
client_id/client_secret/webhook_id,全部由 SDK 自动管理。
正确写法:
// ✅ 正确:只传 supabase,配置由 SDK 内部读取
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
);
const client = await createPayPalClient({ supabase });错误写法:
// ❌ 错误:传 config 覆盖读取规则
const client = await createPayPalClient({
supabase,
config: { clientIdField: "MY_PAYPAL_ID" }, // 禁止手动覆盖
});
// ❌ 错误:用 anon key
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!, // anon key 无法读取配置表
);配置中心字段:SDK 从 Supabase 配置表只读取三个字段(CMS 模式字段名见括号):
| 字段 | CMS 字段名 | 必填 | 说明 |
|------|-----------|------|------|
| client_id | PAYPAL_CLIENT_ID | 是 | PayPal 应用 Client ID |
| client_secret | PAYPAL_CLIENT_SECRET | 是 | PayPal 应用 Client Secret |
| webhook_id | PAYPAL_WEBHOOK_ID | 否 | 仅 verifyWebhook() 需要 |
运行环境(Sandbox / Production):不在配置表里,由环境变量 PAYPAL_ENVIRONMENT 决定:
PAYPAL_ENVIRONMENT=sandbox→ 走 PayPal 沙盒PAYPAL_ENVIRONMENT=live(或production)→ 走 PayPal 生产- 未设置时默认
live,避免生产凭证误打到 sandbox。本地联调请显式设置PAYPAL_ENVIRONMENT=sandbox。
2.2 createOrder — 创建支付订单
type PayPalCreateOrderInput = {
amount: number; // 金额,单位为**最小货币单位**(美分 = 1/100 USD)
currency: "USD" | "CNY"; // 货币代码
orderId: string; // 业务订单 ID,必须唯一
description: string; // 支付描述,用户可在 PayPal 页面看到
returnUrl: string; // 审批成功回跳地址
cancelUrl: string; // 审批取消回跳地址
brandName?: string; // PayPal 审批页展示的品牌名
locale?: string; // 审批页语言,如 "en-US"
};
type PayPalCreateOrderResult = {
paypalOrderId: string; // PayPal Order ID,捕获时需要
orderId: string; // 业务订单 ID(回传)
approveUrl: string; // 前端跳转地址
status: string; // PayPal 订单状态
};强约束:
amount单位是最小货币单位:USD 传美分($19.99 →1999),CNY 传分(¥19.99 →1999)。orderId必须全局唯一,建议格式:order_${userId}_${timestamp}。returnUrl和cancelUrl必须是完整的绝对 URL,不能是相对路径。- 业务侧需保存
paypalOrderId与orderId的对应关系,回跳捕获时要用。
2.3 captureOrder — 捕获已审批订单
用户在 PayPal 审批后回跳到 returnUrl,服务端拿 paypalOrderId 捕获款项。这一步是收款关键,缺失则资金不会真正到账。
type PayPalCaptureOrderInput = {
paypalOrderId: string; // createOrder 返回的 PayPal Order ID
};
type PayPalCaptureOrderResult = {
captureId: string; // PayPal 捕获 ID
paypalOrderId: string; // PayPal Order ID(回传)
orderId: string; // 业务订单 ID
status: "success" | "failed" | "pending";
amount: number; // 最小货币单位(美分 / 分)
currency: "USD" | "CNY";
raw: unknown; // PayPal 原始返回
};强约束:
captureOrder()只能在服务端调用,不要让前端直接持有凭证去捕获。- 回跳地址
returnUrl上的token查询参数即为paypalOrderId,前端把它发回服务端捕获。 - 以
status === "success"作为入账依据,不要只凭"用户回跳成功"就认为已支付。
2.4 verifyWebhook — 验签
type PayPalWebhookEvent = {
eventType: string; // PayPal 事件类型
captureId?: string;
paypalOrderId: string;
orderId: string;
status: "success" | "failed" | "pending";
amount: number;
currency?: "USD" | "CNY";
raw: unknown;
};强约束(必须遵守):
body必须是原始请求体字符串,不能先JSON.parse()再传入。- Webhook 路由必须使用
express.raw({ type: "application/json" })中间件。 headers必须保留 PayPal 原始请求头(paypal-transmission-id、paypal-transmission-time、paypal-cert-url、paypal-auth-algo、paypal-transmission-sig),直接透传req.headers。
事件映射:PAYMENT.CAPTURE.COMPLETED → "success",PAYMENT.CAPTURE.DENIED / PAYMENT.CAPTURE.DECLINED → "failed",CHECKOUT.ORDER.APPROVED → "pending"
正确写法:
// ✅ 正确:express.raw 中间件 + 原始 body + 透传 headers
router.post(
"/api/payment/webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
const event = await client.verifyWebhook(
req.headers,
req.body.toString("utf8"), // 原始 buffer 转 string
);
},
);错误写法:
// ❌ 错误:先 JSON.parse 再传入
router.post("/api/payment/webhook", async (req, res) => {
const body = JSON.parse(req.body); // 已经丢失原始格式,验签必然失败
const event = await client.verifyWebhook(req.headers, body);
});
// ❌ 错误:使用 express.json() 中间件
router.post(
"/api/payment/webhook",
express.json(), // json() 会自动 parse,body 已不是原始格式
async (req, res) => { ... },
);2.5 webhook_id 配置(无自动注册)
PayPal Server SDK 当前不支持 webhook 管理接口,本包不提供自动注册。需要:
- 在 PayPal 后台手动创建 webhook,回调地址指向
/api/payment/webhook。 - 把生成的
webhook_id写入 Supabase 配置中心(默认字段webhook_id,CMS 模式字段PAYPAL_WEBHOOK_ID)。
说明:webhook_id 只在调用 verifyWebhook() 时需要;若当前需求不验签可暂不配置。
2.6 其他 API
| 方法 | 说明 |
|------|------|
| client.isSandbox | 当前是否为沙盒环境(environment === "sandbox") |
| initPayPal(options) / paypal | 初始化默认单例并复用 |
| verifyPayPalWebhook(headers, body, options) | 低级验签函数,适用于已有自己凭证来源的场景,从 @wecode-team/payment-paypal/webhook 导入 |
3. 默认落地路径
本项目使用 Razzle / Express,不要按 Next.js App Router 理解。
AI 必须生成以下三个接口,不需要等用户定义:
3.1 POST /api/payment/create-paypal-order
职责:接收前端请求,创建 PayPal Order,返回审批跳转 URL。
放置位置:src/routes/payment.ts 或 src/api/payment.ts(根据项目现有路由结构调整)。
import express from "express";
import { createClient } from "@supabase/supabase-js";
import { createPayPalClient } from "@wecode-team/payment-paypal";
const router = express.Router();
router.post("/api/payment/create-paypal-order", async (req, res) => {
try {
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
);
const client = await createPayPalClient({ supabase });
const order = await client.createOrder({
amount: req.body.amount,
currency: req.body.currency,
orderId: req.body.orderId,
description: req.body.description,
returnUrl: req.body.returnUrl,
cancelUrl: req.body.cancelUrl,
brandName: req.body.brandName,
locale: req.body.locale,
});
res.json({ code: 200, message: "success", data: order });
} catch (error) {
const message = (error as Error).message;
console.error("[payment] create-paypal-order failed:", message);
res.status(500).json({ code: 500, message });
}
});3.2 POST /api/payment/capture-paypal-order
职责:用户审批回跳后,捕获已审批的 PayPal Order,确认收款。
放置位置:同上,与 create 路由放在同一文件。
router.post("/api/payment/capture-paypal-order", async (req, res) => {
try {
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
);
const client = await createPayPalClient({ supabase });
const capture = await client.captureOrder({
paypalOrderId: req.body.paypalOrderId,
});
console.log("[payment] capture:", capture.status, capture.orderId);
if (capture.status === "success") {
// TODO: 标记订单支付成功,更新数据库
// await db.update(orders).set({ status: "paid" }).where(eq(orders.id, capture.orderId));
}
res.json({ code: 200, message: "success", data: capture });
} catch (error) {
const message = (error as Error).message;
console.error("[payment] capture-paypal-order failed:", message);
res.status(500).json({ code: 500, message });
}
});3.3 POST /api/payment/webhook
职责:接收 PayPal webhook 回调,验签,处理支付结果(兜底,防止前端未触发捕获)。
放置位置:同上,与其他支付路由放在同一文件。
router.post(
"/api/payment/webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
try {
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
);
const client = await createPayPalClient({ supabase });
const event = await client.verifyWebhook(
req.headers,
req.body.toString("utf8"),
);
console.log("[payment] webhook received:", event.status, event.orderId);
if (event.status === "success") {
// TODO: 标记订单支付成功,更新数据库
} else if (event.status === "failed") {
// TODO: 标记订单支付失败
}
res.json({ received: true });
} catch (error) {
const message = (error as Error).message;
console.error("[payment] webhook verification failed:", message);
res.status(400).json({ code: 400, message });
}
},
);3.4 前端跳转与回跳捕获
// 1. 创建 order,跳转到 PayPal 审批页
const res = await fetch("/api/payment/create-paypal-order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amount: 1999,
currency: "USD",
orderId: `order_${userId}_${Date.now()}`,
description: "Pro plan",
returnUrl: `${window.location.origin}/payment/result?status=success`,
cancelUrl: `${window.location.origin}/payment/result?status=cancel`,
}),
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: "网络错误" }));
toast.error(error.message || "支付创建失败,请稍后重试");
return;
}
const result = await res.json();
window.location.assign(result.data.approveUrl);
// 2. 用户审批后回跳 returnUrl,PayPal 会带上 token(即 paypalOrderId)
// 前端把它发回服务端完成捕获
const params = new URLSearchParams(window.location.search);
const paypalOrderId = params.get("token");
if (paypalOrderId) {
await fetch("/api/payment/capture-paypal-order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paypalOrderId }),
});
}4. 安全边界
强约束(必须遵守):
| # | 约束 | 违反后果 |
|---|------|----------|
| 1 | 禁止将 client_secret / access token / webhook_id 暴露给前端 | 凭证泄露,资金风险 |
| 2 | Webhook 路由必须保留原始 body,不能先 JSON.parse | 验签必然失败 |
| 3 | amount 参数必须在服务端校验范围(如最低 $1.00) | 防止前端篡改金额 |
| 4 | 以服务端 captureOrder / webhook 的 success 为入账依据 | 防止仅凭回跳就误判已支付 |
| 5 | 禁止在前端硬编码 PayPal 凭证 | 凭证泄露 |
5. 错误处理
5.1 SDK 层错误
| 场景 | 错误信息 | 处理方式 |
|------|----------|----------|
| Supabase 连接失败 | Failed to load config | 检查 SUPABASE_URL 和 SUPABASE_SERVICE_ROLE_KEY 环境变量 |
| 配置表不存在 / 无记录 | No config row found | 确认 Supabase 配置表已创建且包含 PayPal 凭证 |
| 凭证为空 | "client_id" is empty / "client_secret" is empty | 在配置表中补全 client_id / client_secret |
| webhook_id 缺失 | webhook_id not found in config | 在 PayPal 后台创建 webhook 并把 webhook_id 写入配置表 |
| Webhook 验签失败 | PayPal webhook verification failed | 确认 body 是原始格式,确认 webhook_id 与回调来源一致 |
5.2 前端错误处理
前端必须处理以下场景:
// 1. 网络错误
if (!res.ok) {
const error = await res.json().catch(() => ({ message: "网络错误" }));
toast.error(error.message || "支付创建失败,请稍后重试");
return;
}
// 2. 跳转失败(PayPal 页面加载失败)
window.location.assign(result.data.approveUrl);
// 无需额外处理,浏览器会显示 PayPal 的错误页面
// 3. 回调页面
// success 页面:发起捕获 → 展示支付成功 + 订单详情
// cancel 页面:展示支付取消 + 重新支付按钮6. 平台生成场景职责边界
| 角色 | 职责 |
|------|------|
| 平台层 | 在 PayPal 后台创建 webhook,把 webhook_id 写入配置中心 |
| AI | 注册支付相关 route,调用 SDK |
| SDK | 读取凭证、创建 order、捕获 order、验签 |
| 用户 | 不需要在代码里手填任何凭证 |
默认路径是"服务端创建 order + 回跳后服务端捕获 + webhook 兜底",而不是让前端直接对接 PayPal 凭证。
7. 规则摘要
- 唯一必填参数:
createPayPalClient({ supabase }),禁止传config覆盖读取规则。 - 三个默认接口:
POST /api/payment/create-paypal-order+POST /api/payment/capture-paypal-order+POST /api/payment/webhook,AI 直接生成,不需要等用户定义。 - 必须捕获:用户审批回跳后服务端调用
captureOrder,以success为入账依据。 - Webhook 原始 body:必须用
express.raw()中间件,透传req.headers,不能先JSON.parse()。 - 前端只做跳转:发请求 → 拿
data.approveUrl→window.location.assign(),回跳后把token发回捕获。 - 不要发明抽象层:禁止生成
PaymentService、PayPalManager、PaymentProvider等,直接 route → SDK。 - webhook_id 手动配置:PayPal SDK 不支持自动注册,需在后台创建 webhook 并写入配置中心。
- 安全底线:
client_secret/webhook_id绝不暴露给前端,amount服务端校验。
