PAYATHON 2026

如何为电商网站实现 Quickbook(Intuit Payment)支付流程?

支付小周

结论

电商网站应接入 QuickBooks Payments(Intuit Payments),不能把 QuickBooks 会计接口当成支付接口。

完整、安全的支付流程通常如下:

  1. 商户通过 OAuth 2.0 授权 Intuit 应用;
  2. 前端使用 Intuit 提供的安全组件采集银行卡信息并生成 token;
  3. 后端使用该 token 创建 charge;
  4. 将支付结果、订单号和 Intuit 交易 ID 保存到数据库;
  5. 按业务需要,将订单同步为 QuickBooks Online 中的 Invoice、SalesReceipt 或 Payment;
  6. 退款、撤销和交易查询均由后端执行。

银行卡号不能经过自己的服务器。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 后,后端需要:

  1. 根据 orderId 查询订单;
  2. 验证订单是否属于当前用户;
  3. 检查订单是否已经支付;
  4. 在服务端重新计算最终金额;
  5. 获取有效的 Intuit access token;
  6. 调用 Payments API 创建 charge;
  7. 在同一事务中更新订单和支付记录。

示例:

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。

备注:内容仅供参考。