PAYATHON 2026

PHP 应用如何集成面向付款方的 payment gateway?

支付小周

结论

这类需求通常不适合使用传统的 payment gateway(收款网关),更适合采用 payout / mass payments(批量付款)、payroll(薪资发放)或 accounts payable(应付账款)方案。

员工一般不必注册 seller account,但付款平台仍会收集并验证收款人的身份、开户地址、银行账户和税务信息。这些资料用于 KYC、AML、受限主体合规筛查及银行合规,无法靠技术集成绕过。

可以根据人员关系选择方案:

  • 美国雇员:优先使用合规的 payroll 服务,通过 ACH 发薪。
  • 海外雇员:使用覆盖目标国家的全球 payroll 或 Employer of Record(EOR)服务。
  • 独立承包商:使用 contractor payment 或 mass payout 平台。
  • 少量特殊付款:通过银行的 ACH、international wire 或当地转账网络处理。

Wise Business、Airwallex、Payoneer、Deel、Remote 等服务可能覆盖其中一部分场景,但它们支持的国家、人员类型、资金来源和 API 权限会发生变化。接入前必须按收款国家逐一核实。Stripe Connect 等 marketplace 产品虽然支持 payouts,但通常需要为收款方创建 connected account,也未必允许用于员工工资发放。

为什么普通 payment gateway 不适合

Payment gateway 主要用于客户向商户付款:

Customer card → Payment gateway → Merchant account

这里需要处理的资金方向正好相反:

Company bank account → Payout provider → Employee bank account

两者的账户模型和监管要求有明显区别:

能力Payment gatewayPayout / payroll
主要用途向客户收款向个人或企业付款
资金方向付款人到商户企业到多个收款人
收款人模型商户或 connected accountemployee、contractor、beneficiary
银行转账通常用于收款ACH、wire、local bank transfer
工资税处理通常不提供payroll 服务通常提供
批量付款不一定支持通常支持

如果系统要求每位员工拥有 seller account,通常说明选用的是 marketplace 收款产品,而非企业付款产品。

先确定员工的法律关系

选择方案前,首先要确认收款人与公司的法律关系。不能把人员信息和银行账号直接提交给任意转账 API。

员工

如果对方在法律上属于 employee,付款通常还涉及:

  • 工资代扣;
  • 社会保险或当地法定缴费;
  • 工资单;
  • 最低工资和发薪周期;
  • 劳动法规定;
  • 美国或所在地的税务申报。

雇用海外员工还可能让公司在当地面临雇佣实体、常设机构或税务方面的风险。如果公司没有当地实体,常见做法是使用 EOR,由 EOR 在当地合法雇用员工并发放工资。

独立承包商

如果对方属于 independent contractor,通常可以通过批量付款平台付款,但仍需处理:

  • 合同和发票;
  • W-8BEN、W-8BEN-E 或 W-9 等适用税务文件;
  • 收款人身份验证;
  • 年度申报义务;
  • 汇率、手续费和付款凭证。

一个人实际属于员工还是承包商,不能只看合同名称。如果存在疑问,应请熟悉美国及人员所在地法律的会计师或律师确认。

推荐的系统架构

不要让 PHP 应用直接保存完整的银行账户资料。可以采用下面的架构:

Invoice application
        │
        ├── 计算应付金额并完成内部审批
        │
        ├── 保存 provider recipient ID
        │
        ▼
Payout / payroll provider
        │
        ├── KYC / AML / sanctions screening
        ├── Currency conversion
        ├── ACH / wire / local transfer
        ▼
Employee bank account

Provider webhook
        │
        ▼
Invoice application updates payment status

应用内部可以拆分为以下对象:

  • workers:人员及雇佣类型;
  • invoices:发票或工资计算依据;
  • payouts:实际付款记录;
  • payout_attempts:每次调用付款 API 的结果;
  • webhook_events:已经处理的回调事件。

payouts 至少应包含:

id
worker_id
provider
provider_recipient_id
provider_transfer_id
amount
currency
status
idempotency_key
approved_by
created_at
updated_at

金额不要使用 PHP 浮点数,应按最小货币单位保存。例如,USD 125.40 应保存为 12540 cents。

接入步骤

1. 建立付款国家和人员类型清单

逐一列出:

  • 收款人所在国家;
  • employee 或 contractor;
  • 付款币种;
  • 预计金额和频率;
  • 希望到账的银行网络;
  • 公司是否在当地拥有实体。

没有一家服务能够覆盖所有国家和所有人员类型。必要时可以接入多个 provider,并在业务层通过统一接口隔离各平台之间的差异。

2. 核实资金来源

平台声称“支持 credit/debit cards”,不代表可以使用信用卡给员工发工资。很多 payout 服务只接受:

  • 公司银行账户;
  • ACH debit;
  • wire transfer;
  • 预充值余额。

信用卡资金可能被视为 cash advance,也可能因为拒付风险、反洗钱要求或 card network 规则而无法使用。所谓 net banking 也不是全球统一的付款方式,需要进一步确认对应的国家和银行网络。

对于持续发薪,公司银行账户通常是最稳定的资金来源。

3. 由平台完成收款人开户和验证

较合适的流程是:

  1. PHP 应用创建 recipient;
  2. 平台返回托管的 onboarding URL;
  3. 员工在平台页面提交身份和银行资料;
  4. 应用只保存 recipient_id 和验证状态;
  5. 验证通过后再创建付款。

这样可以减少银行账号和身份证件进入自有系统后带来的安全及合规负担。

4. 增加内部审批

发起真实付款前,至少应确认:

  • invoice 已审核;
  • 收款人已通过验证;
  • 币种与国家匹配;
  • 金额没有超过审批限额;
  • 同一 invoice 未被重复付款;
  • 操作人拥有付款权限。

金额较大时建议采用双人审批,也不要让录入银行账户的人同时批准付款。

5. 使用 webhook 跟踪最终状态

成功创建 payout 通常只表示平台接受了请求,不代表款项已经进入银行账户。状态可能依次变化:

pending → processing → paid
                    └→ failed
                    └→ returned
                    └→ canceled

数据库中的状态应以通过签名验证的 webhook 和后续查询结果为准。

PHP 集成示例

各平台使用的 URL、字段和签名算法不同。下面只展示与供应商无关的代码结构,实际接入时必须换成所选平台官方文档规定的 endpoint、认证方式和参数。

定义统一的付款接口

<?php

declare(strict_types=1);

interface PayoutProvider
{
    /**
     * @return array{
     *     transfer_id: string,
     *     status: string
     * }
     */
    public function createPayout(
        string $recipientId,
        int $amountMinor,
        string $currency,
        string $idempotencyKey
    ): array;
}

调用 payout API

<?php

declare(strict_types=1);

final class HttpPayoutProvider implements PayoutProvider
{
    public function __construct(
        private readonly string $baseUrl,
        private readonly string $apiToken
    ) {
    }

    public function createPayout(
        string $recipientId,
        int $amountMinor,
        string $currency,
        string $idempotencyKey
    ): array {
        if ($amountMinor <= 0) {
            throw new InvalidArgumentException('Payout amount must be positive.');
        }

        $payload = json_encode([
            'recipient_id' => $recipientId,
            'amount' => $amountMinor,
            'currency' => strtoupper($currency),
        ], JSON_THROW_ON_ERROR);

        $ch = curl_init($this->baseUrl . '/payouts');

        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . $this->apiToken,
                'Content-Type: application/json',
                'Idempotency-Key: ' . $idempotencyKey,
            ],
            CURLOPT_POSTFIELDS => $payload,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_TIMEOUT => 30,
        ]);

        $responseBody = curl_exec($ch);

        if ($responseBody === false) {
            $message = curl_error($ch);
            curl_close($ch);

            throw new RuntimeException('Payout API connection failed: ' . $message);
        }

        $statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        $response = json_decode(
            $responseBody,
            true,
            flags: JSON_THROW_ON_ERROR
        );

        if ($statusCode < 200 || $statusCode >= 300) {
            throw new RuntimeException(
                'Payout API rejected the request. HTTP status: ' . $statusCode
            );
        }

        if (!isset($response['id'], $response['status'])) {
            throw new RuntimeException('Unexpected payout API response.');
        }

        return [
            'transfer_id' => (string) $response['id'],
            'status' => (string) $response['status'],
        ];
    }
}

调用时,需要为每一笔业务付款生成稳定且唯一的幂等键:

<?php

$idempotencyKey = hash(
    'sha256',
    sprintf('invoice:%d:payout:v1', $invoiceId)
);

$result = $provider->createPayout(
    recipientId: $worker->providerRecipientId,
    amountMinor: 12540,
    currency: 'USD',
    idempotencyKey: $idempotencyKey
);

重试同一笔付款时,必须继续使用原来的 Idempotency-Key,否则网络超时可能导致重复转账。

验证 webhook

以下代码只演示常见的 HMAC 验签模式。具体平台可能采用不同的 header、时间戳拼接方式、公钥签名或证书验证,因此不能直接照搬。

<?php

declare(strict_types=1);

$payload = file_get_contents('php://input');
$receivedSignature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$webhookSecret = getenv('PAYOUT_WEBHOOK_SECRET');

if ($payload === false || $webhookSecret === false) {
    http_response_code(500);
    exit;
}

$expectedSignature = hash_hmac('sha256', $payload, $webhookSecret);

if (!hash_equals($expectedSignature, $receivedSignature)) {
    http_response_code(401);
    exit;
}

$event = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);

$eventId = $event['id'] ?? null;
$transferId = $event['data']['transfer_id'] ?? null;
$status = $event['data']['status'] ?? null;

if (!is_string($eventId) || !is_string($transferId) || !is_string($status)) {
    http_response_code(400);
    exit;
}

// 在事务中检查 eventId 是否已处理,然后更新 payout 状态。
// 必须对 eventId 建立唯一索引,避免重复 webhook 造成重复处理。

http_response_code(204);

Webhook 端点还应验证时间戳,并设置允许的时间偏差,以降低重放攻击的风险。

供应商评估清单

和平台沟通时,不要只问“是否有 payment gateway”,应直接确认:

  • 是否允许美国公司向目标国家的 employee 或 contractor 付款;
  • 是否支持 mass payouts、payroll 或 contractor payments;
  • 收款人是否必须拥有平台账户;
  • 是否提供 hosted onboarding;
  • 支持哪些当地银行转账网络;
  • 可以使用哪些 funding sources;
  • 是否支持 API、sandbox 和 signed webhooks;
  • 是否提供幂等请求;
  • 是否处理外汇转换;
  • 费用由公司还是收款人承担;
  • 如何处理失败、退回和账号错误;
  • 是否提供付款凭证和对账文件;
  • 是否处理工资税、税务表格和当地申报;
  • 哪些国家、行业或付款用途受到限制。

即使平台表示“收款人不需要 seller account”,也要继续确认是否需要 recipient profile、beneficiary verification 或托管开户,因为这些概念并不相同。

安全与合规注意事项

  • API token 应保存在 secret manager 或受保护的环境变量中,不能写入源码。
  • 生产环境与 sandbox 应使用不同的凭据。
  • 不要记录完整银行账号、身份证件、API token 或原始 webhook secret。
  • 如果必须显示银行账号,只保留后四位。
  • 管理后台应启用 MFA、最小权限和付款审批。
  • 保存操作审计日志,但不要在日志中写入敏感数据。
  • 定期核对 invoice、内部 payout 记录、平台账单和银行流水。
  • 对失败和退回的付款进行人工复核,不要无限自动重试。
  • 不要只根据前端返回页面将付款标记为成功。
  • 不要把信用卡收款接口改造成“向任意银行卡付款”的接口。这种做法通常不受支付网络支持,还会带来严重的合规风险。

最终可以由 PHP 应用负责 invoices、审批和对账,再由受监管的 payroll 或 payout 平台完成身份验证、银行转账、换汇和实际资金转移。员工不必成为 seller,公司也不用自行建设整套跨境支付和工资合规基础设施。

备注:内容仅供参考。