PAYATHON 2026

如何让用户安全地接收其他用户的在线付款?

支付小周

结论

建议采用支付机构提供的“平台型支付”或“市场平台支付”方案。租金不应先进入平台自己的银行账户,再由平台转给房东。

基本模型如下:

  • 平台拥有一个主平台账户。
  • 每位房东在支付机构处开通独立的商户账户或 connected account。
  • 租客付款时,交易直接创建在对应房东的账户下。
  • 支付机构负责身份验证、收款、结算、退款和争议处理。
  • CRM 只保存交易状态、账单记录和支付机构返回的 ID,既不保存银行卡资料,也不实际搬运资金。
  • 房东向平台支付的 CRM 服务费通过另一条独立支付链路收取。

可选产品包括 Stripe Connect、Adyen for Platforms、PayPal 的多方支付方案,以及所在地银行或收单机构提供的平台商户解决方案。产品能否使用、支持哪些账户类型、各方如何承担责任,取决于平台、房东和租客所在的国家或地区,应以支付机构当前的服务范围和合同为准。

推荐的账户模型

系统至少需要区分三类对象:

平台
├── 收取 CRM 服务费
└── 管理房东的支付账户关联

房东 A(独立支付账户)
├── 租客 A1 的租金
└── 租客 A2 的租金

房东 B(独立支付账户)
└── 租客 B1 的租金

数据库可以保存以下字段:

landlords
- id
- payment_provider
- provider_account_id
- onboarding_status
- payments_enabled
- payouts_enabled

leases
- id
- landlord_id
- tenant_id
- monthly_rent
- currency

rent_invoices
- id
- lease_id
- amount
- due_date
- status
- provider_payment_id
- idempotency_key

provider_account_id 是房东在支付机构中的账户标识。租客不应提交这个值,服务器应根据租约查询对应的账户。

完整付款流程

1. 为房东开通独立支付账户

房东首次启用在线收租时,平台通过支付机构创建 connected account,再将房东重定向到支付机构托管的开户页面。

支付机构通常会收集:

  • 姓名或企业名称
  • 身份证明
  • 税务资料
  • 银行账户
  • 实际控制人信息
  • 所在国家或地区要求的其他 KYC/KYB 资料

应尽量使用支付机构托管的 onboarding 页面,避免自行收集和保存身份证、银行账户等敏感资料。

以下使用 Stripe Connect 的 PHP SDK 演示集成结构。账户类型、国家和 capability 需要根据实际业务地区调整:

<?php

use Stripe\StripeClient;

$stripe = new StripeClient(getenv('STRIPE_SECRET_KEY'));

$account = $stripe->accounts->create([
    'type' => 'express',
    'country' => 'US',
    'email' => $landlordEmail,
    'capabilities' => [
        'card_payments' => ['requested' => true],
        'transfers' => ['requested' => true],
    ],
]);

// 保存到 landlords.provider_account_id
$providerAccountId = $account->id;

$accountLink = $stripe->accountLinks->create([
    'account' => $providerAccountId,
    'refresh_url' => site_url('payments/onboarding/refresh'),
    'return_url' => site_url('payments/onboarding/complete'),
    'type' => 'account_onboarding',
]);

return redirect()->to($accountLink->url);

用户返回 return_url 并不代表开户已经成功。平台应重新查询账户状态,或者通过经过签名验证的 webhook 更新以下信息:

  • 是否完成资料提交
  • 是否允许收款
  • 是否允许结算到银行账户
  • 是否仍需补充资料

2. 由服务器确定租金归属

租客发起支付时,只需向服务器提交账单 ID:

{
  "invoice_id": 1842
}

服务器根据当前登录用户查询:

  1. 该账单是否属于当前租客。
  2. 账单关联哪一份租约。
  3. 租约属于哪位房东。
  4. 房东对应哪个支付账户。
  5. 应付金额和币种是多少。
  6. 账单是否已经支付或正在处理。

不要接受浏览器直接提交的金额、房东账户 ID 或收款银行信息。否则,攻击者可能篡改参数,把款项转给错误账户或少付租金。

3. 在房东账户下创建付款

如果支付机构支持 direct charge,应在房东的 connected account 下直接创建 PaymentIntent 或同等支付对象。

CodeIgniter 4 控制器可以采用以下结构:

<?php

namespace App\Controllers;

use Stripe\StripeClient;

class RentPayments extends BaseController
{
    public function createPaymentIntent()
    {
        $invoiceId = (int) $this->request->getPost('invoice_id');
        $tenantId = (int) session('user_id');

        $invoiceModel = model('RentInvoiceModel');

        $invoice = $invoiceModel
            ->select(
                'rent_invoices.*, leases.tenant_id, leases.landlord_id, ' .
                'landlords.provider_account_id, landlords.payments_enabled'
            )
            ->join('leases', 'leases.id = rent_invoices.lease_id')
            ->join('landlords', 'landlords.id = leases.landlord_id')
            ->where('rent_invoices.id', $invoiceId)
            ->first();

        if (!$invoice || (int) $invoice['tenant_id'] !== $tenantId) {
            return $this->response
                ->setStatusCode(404)
                ->setJSON(['error' => 'Invoice not found']);
        }

        if ($invoice['status'] === 'paid') {
            return $this->response
                ->setStatusCode(409)
                ->setJSON(['error' => 'Invoice already paid']);
        }

        if (!$invoice['payments_enabled']) {
            return $this->response
                ->setStatusCode(409)
                ->setJSON(['error' => 'Landlord cannot currently accept payments']);
        }

        $stripe = new StripeClient(getenv('STRIPE_SECRET_KEY'));

        $paymentIntent = $stripe->paymentIntents->create(
            [
                'amount' => (int) $invoice['amount_minor'],
                'currency' => strtolower($invoice['currency']),
                'automatic_payment_methods' => [
                    'enabled' => true,
                ],
                'metadata' => [
                    'invoice_id' => (string) $invoice['id'],
                    'lease_id' => (string) $invoice['lease_id'],
                ],
            ],
            [
                'stripe_account' => $invoice['provider_account_id'],
                'idempotency_key' => 'rent-invoice-' . $invoice['id'],
            ]
        );

        $invoiceModel->update($invoice['id'], [
            'provider_payment_id' => $paymentIntent->id,
            'status' => 'payment_pending',
        ]);

        return $this->response->setJSON([
            'client_secret' => $paymentIntent->client_secret,
            'connected_account_id' => $invoice['provider_account_id'],
        ]);
    }
}

这段代码假设金额已经按最小货币单位保存,例如美元使用美分。不同币种的小数位规则并不完全相同,不能统一按两位小数处理。

前端应使用支付机构提供的托管结账页或安全支付组件。如果 PaymentIntent 创建在 connected account 下,前端 SDK 通常也需要初始化到同一个账户上下文。具体写法以所用 SDK 的当前版本为准。

以 webhook 确认付款结果

浏览器跳转到“付款成功”页面后,不能直接把账单标记为已支付。用户可能关闭页面或伪造请求,付款也可能仍在银行处理中。

服务器应接收支付机构发送的 webhook:

<?php

use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;

$payload = $this->request->getBody();
$signature = $this->request->getHeaderLine('Stripe-Signature');
$webhookSecret = getenv('STRIPE_CONNECT_WEBHOOK_SECRET');

try {
    $event = Webhook::constructEvent(
        $payload,
        $signature,
        $webhookSecret
    );
} catch (\UnexpectedValueException | SignatureVerificationException $e) {
    return $this->response->setStatusCode(400);
}

switch ($event->type) {
    case 'payment_intent.succeeded':
        $paymentIntent = $event->data->object;
        $invoiceId = $paymentIntent->metadata->invoice_id ?? null;

        if ($invoiceId !== null) {
            // 再次核对 provider_payment_id、金额、币种和 connected account
            // 确认一致后,以幂等方式把账单更新为 paid
        }
        break;

    case 'payment_intent.payment_failed':
        // 记录失败状态和可公开给租客的错误信息
        break;

    case 'charge.refunded':
    case 'charge.dispute.created':
        // 更新退款或争议状态,不要删除原交易记录
        break;
}

return $this->response->setStatusCode(200);

使用 Connect webhook 时,还要核对事件所属的 connected account,确认它与账单对应的房东一致。Webhook 可能重复发送,也可能乱序到达,因此处理程序必须具备幂等性。

平台服务费如何处理

房东支付 CRM 订阅费和租客支付租金属于两种不同的商业关系,最好分别建模。

比较清晰的做法是:

  • 房东在平台自己的商户账户中购买 CRM 套餐。
  • 租客在对应房东的 connected account 中支付租金。
  • 两类付款分别使用不同的订单、发票和 webhook 状态。

如果平台希望从每笔租金中收取佣金,可以研究 application_fee 或支付机构提供的分账能力。但这会改变资金流、退款规则、争议责任以及平台承担的法律角色。如果平台只收取固定的软件服务费,单独设置订阅通常更容易对账,也更符合“平台不经手租金”的设计。

Direct charge 与平台分账的区别

常见方案大致分为三类:

模式资金路径适用情况
Direct charge交易建立在房东账户下,由支付机构向房东结算希望各房东的交易尽量隔离
Destination charge平台创建交易,再由支付机构把款项分配到指定账户平台需要控制交易和分账
Separate charges and transfers平台先收款,再创建一笔或多笔转账复杂市场、多人分账或延迟分配

按照问题中的责任隔离要求,可以优先评估 direct charge。不过,交易创建在房东账户下,并不代表平台自然免除所有责任。退款、拒付、负余额、欺诈损失和客户支持责任,仍由账户类型、支付机构规则和平台合同共同决定。

如果房东需要更独立的商户关系,可以继续比较托管程度较低的账户模式。相应的开户流程、权限控制和用户体验通常也会更复杂。

不要自行保存银行卡或银行账户

建议使用:

  • Hosted Checkout
  • Payment Element
  • Hosted Fields
  • 支付机构生成的 token 或 PaymentMethod ID

平台数据库只保存必要的引用 ID,例如:

provider_customer_id
provider_payment_method_id
provider_payment_id
provider_account_id

不要保存:

  • 完整银行卡号
  • CVV/CVC
  • 网上银行密码
  • 未加密的银行账户资料
  • 支付机构的 secret key

Secret key 应保存在服务器环境变量或密钥管理系统中,不能写入 Git 仓库,也不能发送到浏览器。

即使使用托管支付组件,平台仍需确认自身的 PCI DSS 合规范围,不能简单认为使用支付网关后就不再需要合规。

租金支付更适合银行扣款,但要处理延迟

租金通常金额较高,而且按月重复。除银行卡外,还可以评估业务所在地支持的银行扣款方式,例如 ACH Direct Debit、SEPA Direct Debit 或当地银行转账方案。

银行扣款的手续费可能更适合租金场景,但系统还需要处理:

  • 用户授权和 mandate
  • 到账延迟
  • 付款退回
  • 余额不足
  • 撤销授权
  • 节假日和银行工作日
  • 付款状态由 processing 变为 succeeded 或 failed

租客刚提交银行扣款时,不应立即将租金视为最终到账。

上线前需要确认的责任边界

connected account 可以在技术上隔离账户和交易,但不能替代法律及合规设计。上线前至少应向支付机构和专业顾问确认:

  • 谁是租客账单上的 merchant of record。
  • 谁负责退款、拒付和欺诈损失。
  • 房东账户出现负余额时由谁承担。
  • 平台是否构成支付服务商、资金传输方或商业代理。
  • 是否允许收取住宅租金。
  • 是否支持业务所在国家、币种和房东主体类型。
  • 是否需要保存租金收据和税务记录。
  • 租客的付款凭证应显示平台名称还是房东名称。
  • 房东退出平台后,历史退款和争议如何处理。

最终实现时,CodeIgniter 负责业务权限、账单状态和支付 API 调用。开户、银行卡处理、资金保管及银行结算应交给具备相应资质的平台型支付机构。这样可以在技术上隔离每组房东与租客的付款关系,并尽量避免 CRM 承担资金归集和人工转账责任。

备注:内容仅供参考。