如何让用户安全地接收其他用户的在线付款?
结论
建议采用支付机构提供的“平台型支付”或“市场平台支付”方案。租金不应先进入平台自己的银行账户,再由平台转给房东。
基本模型如下:
- 平台拥有一个主平台账户。
- 每位房东在支付机构处开通独立的商户账户或 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
}
服务器根据当前登录用户查询:
- 该账单是否属于当前租客。
- 账单关联哪一份租约。
- 租约属于哪位房东。
- 房东对应哪个支付账户。
- 应付金额和币种是多少。
- 账单是否已经支付或正在处理。
不要接受浏览器直接提交的金额、房东账户 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 承担资金归集和人工转账责任。
备注:内容仅供参考。
