如何为电商网站实现 Quickbook(Intuit Payment)支付流程?
结论
电商网站应接入 QuickBooks Payments(Intuit Payments),不能把 QuickBooks 会计接口当成支付接口。
完整、安全的支付流程通常如下:
- 商户通过 OAuth 2.0 授权 Intuit 应用;
- 前端使用 Intuit 提供的安全组件采集银行卡信息并生成 token;
- 后端使用该 token 创建 charge;
- 将支付结果、订单号和 Intuit 交易 ID 保存到数据库;
- 按业务需要,将订单同步为 QuickBooks Online 中的 Invoice、SalesReceipt 或 Payment;
- 退款、撤销和交易查询均由后端执行。
银行卡号不能经过自己的服务器。OAuth access token、client secret 和 Payments API 凭据也不能暴露在浏览器中。
注意:Intuit 可能调整产品名称、SDK、接口路径和支持地区。下文展示的是支付集成的通用结构。具体 endpoint、请求字段和账户资格,应以当前 QuickBooks Payments 官方文档及开发者后台配置为准。
先区分两种接口
实现前,需要先分清以下两套接口:
- QuickBooks Online Accounting API:用于创建 Customer、Invoice、SalesReceipt、Payment 等会计对象。
- QuickBooks Payments API:用于银行卡支付、扣款、退款和支付交易查询。
仅在 QuickBooks 中创建 Invoice 或 Payment 会计记录,不会自动从顾客的银行卡扣款。实际资金交易必须通过 QuickBooks Payments 完成。
如果只需要让顾客打开 Intuit 托管的账单页面自行付款,可以考虑使用 QuickBooks Online 的在线发票功能。如果要让顾客直接在自己的结账页面付款,则需要接入 Payments API,并确认商户账户支持这项能力。
推荐的系统流程
顾客提交订单
↓
服务端创建 pending 订单并生成内部 order_id
↓
浏览器通过 Intuit 安全组件将卡信息换成 payment token
↓
浏览器把 payment token 和 order_id 发给自己的后端
↓
后端重新读取订单金额并调用 QuickBooks Payments
↓
支付成功:订单改为 paid,保存 transaction_id
支付失败:订单保持 pending/failed
↓
可选:同步 SalesReceipt、Invoice 或 Payment 到 QuickBooks Online
后端必须根据商品、优惠、运费和税费重新计算金额,不能直接采用浏览器提交的金额。
第一步:创建并配置 Intuit 应用
在 Intuit Developer 平台创建应用后,通常还要完成以下配置:
- 配置开发环境和生产环境凭据;
- 配置 OAuth 2.0 redirect URI;
- 申请业务所需的 scope;
- 连接对应的 QuickBooks Online 公司;
- 确认该公司已经开通并获准使用 QuickBooks Payments;
- 分开保存 sandbox 和 production 配置。
常见配置可以放在环境变量中:
INTUIT_CLIENT_ID=...
INTUIT_CLIENT_SECRET=...
INTUIT_REDIRECT_URI=https://shop.example.com/oauth/intuit/callback
INTUIT_ENVIRONMENT=sandbox
client secret、access token 和 refresh token 只能保存在服务端。生产环境中,建议使用密钥管理服务或加密存储,不要将其提交到代码仓库。
第二步:完成 OAuth 2.0 授权
后端先将商户重定向到 Intuit 授权页面。授权回调通常会收到 code、state 和公司标识。
回调处理的核心逻辑如下:
app.get("/oauth/intuit/callback", async (req, res) => {
const { code, state, realmId } = req.query;
if (!code || !state || state !== req.session.intuitOAuthState) {
return res.status(400).send("Invalid OAuth callback");
}
const tokens = await exchangeAuthorizationCode({
code,
redirectUri: process.env.INTUIT_REDIRECT_URI
});
await tokenStore.save({
realmId,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + tokens.expires_in * 1000
});
res.redirect("/admin/payments/connected");
});
exchangeAuthorizationCode() 表示对 Intuit token endpoint 的封装调用。实际参数、Basic Authentication 方式和返回字段,应以当前 OAuth 2.0 文档为准。
还要实现自动刷新:
async function getValidAccessToken(realmId) {
const saved = await tokenStore.find(realmId);
if (!saved) {
throw new Error("QuickBooks account is not connected");
}
if (saved.expiresAt > Date.now() + 60_000) {
return saved.accessToken;
}
const refreshed = await refreshIntuitToken(saved.refreshToken);
await tokenStore.save({
realmId,
accessToken: refreshed.access_token,
refreshToken: refreshed.refresh_token ?? saved.refreshToken,
expiresAt: Date.now() + refreshed.expires_in * 1000
});
return refreshed.access_token;
}
刷新成功后,需要持久化最新的 refresh token。不要假设旧 token 可以无限期重复使用。
第三步:在前端对银行卡进行 tokenization
结账页面不能把卡号提交给自己的 /pay 接口。应加载 Intuit 当前提供的客户端支付组件或 tokenization 方案,将银行卡数据直接交给 Intuit,再取得一次性 token。
以下代码只用于说明前端的职责,不代表任何特定版本 SDK 的真实方法签名:
async function submitCheckout() {
const orderId = document.querySelector("[name=order_id]").value;
// 由 Intuit 当前支持的安全组件生成,卡号不进入本站后端。
const paymentToken = await createIntuitPaymentToken();
const response = await fetch("/api/orders/pay", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": getCsrfToken()
},
body: JSON.stringify({
orderId,
paymentToken
})
});
const result = await response.json();
if (!response.ok) {
showPaymentError(result.message);
return;
}
window.location.assign(`/orders/${orderId}/success`);
}
createIntuitPaymentToken() 必须替换为 Intuit 当前文档指定的前端组件或 SDK 调用,不能自行猜测 SDK 包名或方法名。
第四步:由后端创建支付交易
收到 token 后,后端需要:
- 根据
orderId查询订单; - 验证订单是否属于当前用户;
- 检查订单是否已经支付;
- 在服务端重新计算最终金额;
- 获取有效的 Intuit access token;
- 调用 Payments API 创建 charge;
- 在同一事务中更新订单和支付记录。
示例:
app.post("/api/orders/pay", async (req, res) => {
const { orderId, paymentToken } = req.body;
if (!orderId || !paymentToken) {
return res.status(400).json({ message: "缺少订单号或支付 token" });
}
const order = await orderRepository.findForUser(orderId, req.user.id);
if (!order) {
return res.status(404).json({ message: "订单不存在" });
}
if (order.status === "paid") {
return res.json({
status: "paid",
transactionId: order.paymentTransactionId
});
}
const amount = await pricingService.calculateFinalAmount(order);
const accessToken = await getValidAccessToken(order.realmId);
try {
const charge = await intuitPayments.createCharge({
accessToken,
paymentToken,
amount: amount.toFixed(2),
currency: order.currency,
reference: order.id,
context: `Payment for order ${order.id}`
});
await database.transaction(async (tx) => {
await paymentRepository.insert(tx, {
orderId: order.id,
provider: "intuit",
providerTransactionId: charge.id,
amount,
currency: order.currency,
status: mapIntuitPaymentStatus(charge.status),
rawStatus: charge.status
});
await orderRepository.markPaid(tx, {
orderId: order.id,
paymentTransactionId: charge.id
});
});
return res.json({
status: "paid",
transactionId: charge.id
});
} catch (error) {
await paymentRepository.recordFailure({
orderId: order.id,
provider: "intuit",
errorCode: error.code,
message: sanitizePaymentError(error)
});
return res.status(402).json({
message: toCustomerFriendlyMessage(error)
});
}
});
这里的 intuitPayments.createCharge() 是业务层封装,并不是某个 Intuit SDK 的正式方法名。它应根据当前文档构造实际 HTTP 请求,例如:
async function createCharge(input) {
const payload = {
amount: input.amount,
currency: input.currency,
token: input.paymentToken,
context: {
mobile: false,
isEcommerce: true
},
description: input.context
};
const response = await fetch(resolvePaymentsChargeEndpoint(), {
method: "POST",
headers: {
Authorization: `Bearer ${input.accessToken}`,
Accept: "application/json",
"Content-Type": "application/json",
"Request-Id": createStableRequestId(input.reference)
},
body: JSON.stringify(payload)
});
const body = await response.json();
if (!response.ok) {
throw normalizeIntuitPaymentError(response.status, body);
}
return body;
}
resolvePaymentsChargeEndpoint()、Request-Id 的具体规则和 payload 字段,必须按照当前环境及官方 API 定义实现。不同阶段的 Intuit Payments API 可能采用不同的字段或路径。
第五步:同步 QuickBooks 会计记录
资金交易成功后,可以根据记账方式同步会计数据:
- 立即成交并收款时,通常可以创建
SalesReceipt; - 先出账、后收款时,先创建
Invoice,付款成功后再创建并关联Payment; - 如果其他系统已经完成全部记账,应避免重复创建收入记录。
例如,先创建 Invoice 再关联 Payment 时,需要保存以下映射:
{
"localOrderId": "ORDER-20260829-001",
"quickBooksCustomerId": "123",
"quickBooksInvoiceId": "456",
"quickBooksPaymentId": "789",
"paymentsTransactionId": "provider-transaction-id"
}
Payments API 的交易对象和 Accounting API 的 Payment 对象不是同一个概念。必须保存两者的关联,否则可能出现钱已经扣除但账上没有记录,或者重复记账的问题。
会计数据最好通过后台任务同步。即使 Accounting API 暂时不可用,也不应把已经成功的支付回滚为失败,因为银行卡扣款可能已经发生。
幂等性与重复扣款
超时、断网或重试都可能让支付接口收到重复请求,因此必须做好幂等控制:
- 每个本地订单只能有一笔成功支付;
- 使用稳定且唯一的 request ID;
- 创建 charge 前检查已有交易;
- 请求超时后,不要立即换一个新的 request ID 再次扣款;
- 先通过原 request ID 或交易查询接口确认结果;
- 在数据库中为成功交易建立唯一约束。
例如:
CREATE UNIQUE INDEX uq_successful_payment_order
ON payments (order_id, provider)
WHERE status = 'succeeded';
数据库是否支持带条件索引取决于具体产品。MySQL 等数据库可能需要借助额外字段或事务锁实现同等约束。
退款流程
退款必须引用原支付交易,并由后端发起:
app.post("/api/orders/:orderId/refund", requireAdmin, async (req, res) => {
const payment = await paymentRepository.findSuccessful(req.params.orderId);
if (!payment) {
return res.status(404).json({ message: "未找到可退款的支付记录" });
}
const amount = validateRefundAmount(req.body.amount, payment);
const accessToken = await getValidAccessToken(payment.realmId);
const refund = await intuitPayments.createRefund({
accessToken,
transactionId: payment.providerTransactionId,
amount,
reference: `${payment.orderId}:refund:${req.body.refundRequestId}`
});
await paymentRepository.saveRefund(payment.id, refund);
res.json({
status: refund.status,
refundId: refund.id
});
});
全额退款、部分退款、void 和结算后的 refund 往往对应不同操作。应根据原交易状态选择正确接口。仅把本地订单状态改为 refunded,并不代表资金已经退回。
错误处理
至少要分别处理以下情况:
- OAuth token 过期或授权被撤销;
- 商户未开通 Payments;
- token 无效或已经使用;
- 卡片被拒绝;
- 余额不足;
- 地址或安全码校验失败;
- 请求超时,但交易结果未知;
- 限流;
- 服务端临时错误;
- 支付成功,但本地数据库写入失败;
- 支付成功,但 QuickBooks 会计同步失败。
只向顾客显示容易理解的信息,例如”银行卡未获批准,请更换支付方式”。完整的上游响应应写入受保护的日志,同时移除银行卡信息、token、Authorization header 等敏感数据。
上线前检查
- 使用 sandbox 测试支付成功、拒付、超时和重复提交;
- 确认商户实体和所在地区支持 QuickBooks Payments;
- 将生产凭据与 sandbox 凭据完全隔离;
- 全站启用 HTTPS;
- 不记录银行卡号、CVV 或完整支付 token;
- 确认 OAuth
state能防止 CSRF; - 为支付接口配置用户鉴权、CSRF 防护和频率限制;
- 由后端计算金额,并使用十进制金额类型;
- 保存支付 request ID、transaction ID 和原始状态;
- 建立支付结果查询和人工对账能力;
- 加密 refresh token,并限制访问权限;
- 将支付和会计同步拆成两个可恢复的步骤;
- 根据实际集成方式评估 PCI DSS 合规范围。
如果 Intuit 当前没有向目标地区、商户类型或应用开放 Payments API,就不能只靠 QuickBooks Accounting API 在网站内完成银行卡收款。此时需要改用受支持的支付网关收款,再将交易结果同步到 QuickBooks Online。
备注:内容仅供参考。
