Files
xin-procurement/tests/Feature/MiniOnlinePaymentTest.php
T
2026-08-27 21:17:02 +08:00

393 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Tests\Feature;
use App\Models\BillModel;
use App\Models\NoticeModel;
use App\Models\PaymentModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Services\WangpuPayService;
use Illuminate\Support\Facades\Http;
/**
* 小程序在线支付(旺铺网关 JSAPI):下单 → 调起支付 → 后台通知/主动查询结账
*
* - Http::fake 模拟微信 code2session 与旺铺网关,不触网
* - 结账幂等:重复通知/查询不重复累加门店总采购金额
*/
class MiniOnlinePaymentTest extends ProcurementTestCase
{
private const string SIGN_KEY = 'test-wangpu-sign-key';
protected function setUp(): void
{
parent::setUp();
config([
'services.wechat.mini.appid' => 'wx-mini-test',
'services.wechat.mini.secret' => 'wx-secret-test',
'services.wangpu.base_url' => 'https://wangpu.test',
'services.wangpu.organiz_no' => 'org001',
'services.wangpu.mer_no' => 'mer001',
'services.wangpu.mer_code' => 'code001',
'services.wangpu.term_code' => 'term001',
'services.wangpu.sign_key' => self::SIGN_KEY,
'services.wangpu.payway_code' => 'WECHAT_MINI',
]);
}
/** 模拟微信 code2session + 旺铺统一下单均成功 */
private function fakeGatewaySuccess(string $openid = 'oOpenidTest001'): void
{
Http::fake([
'https://api.weixin.qq.com/*' => Http::response(['openid' => $openid, 'session_key' => 'sk'], 200),
'https://wangpu.test/industrial/payment/order' => Http::response([
'code' => '0000',
'msg' => '调用成功',
'data' => ['order_id' => 'WP202608270001', 'tradeNo' => 'T20260827001', 'user_openid' => $openid],
], 200),
]);
}
/** 造一张指定金额的未支付账单(总额=商品金额) */
private function makeBill(StoreModel $store, string $amount, array $attributes = []): BillModel
{
return BillModel::create(array_merge([
'bill_no' => 'ZD' . random_int(100000000000, 999999999999),
'purchase_id' => PurchaseOrderModel::factory()->create()->id,
'store_id' => $store->id,
'bill_date' => '2026-08-27',
'product_amount' => $amount,
'delivery_fee' => '0.00',
'box_num' => 0,
'tray_num' => 0,
'box_price' => '0.00',
'tray_price' => '0.00',
'added_amount' => '0.00',
'total_amount' => $amount,
'status' => BillModel::STATUS_UNPAID,
], $attributes));
}
/** 造一笔待支付的在线支付单并锁定账单 */
private function makeOnlinePayment(StoreModel $store, string $amount, BillModel ...$bills): PaymentModel
{
$payment = PaymentModel::create([
'payment_no' => 'ZF' . now()->format('Ymd') . random_int(1000, 9999),
'store_id' => $store->id,
'amount' => $amount,
'pay_type' => PaymentModel::TYPE_ONLINE,
'pay_method' => PaymentModel::METHOD_WANGPU,
'voucher_ids' => '',
'status' => PaymentModel::STATUS_PENDING,
'openid' => 'oOpenidTest001',
]);
foreach ($bills as $bill) {
$bill->update(['payment_id' => $payment->id]);
}
return $payment;
}
/** 构造已加签的支付成功通知报文 */
private function signedNotifyParams(PaymentModel $payment, array $overrides = []): array
{
$params = array_merge([
'mer_order_id' => $payment->payment_no,
'order_status' => '1',
'order_amt' => (string) $payment->amount,
'trade_no' => 'T20260827001',
'order_id' => 'WP202608270001',
'order_time' => '2026-08-27 09:59:00',
'trade_time' => '2026-08-27 10:00:00',
'payway_code' => 'WECHAT_MINI',
'mer_no' => 'mer001',
'device_no' => 'dev001',
'order_title' => '账单合并付款',
], $overrides);
$params['sign'] = app(WangpuPayService::class)->sign($params);
return $params;
}
/** 旺铺加签算法:按接口文档示例 golden testMD5 升序拼接 + key */
public function test_sign_matches_document_example(): void
{
config(['services.wangpu.sign_key' => '07714583f82b4db8b675b32cd5e0969743']);
$sign = app(WangpuPayService::class)->sign([
'mer_order_id' => 'CBC92E5GTL000083202004121010143',
'trade_no' => '11420200410120144102483',
'mer_no' => '2001071119360E5Riu',
'order_amt' => '0.01',
'payway_code' => 'QR_WECHAT_BARPAY',
'order_id' => '202004101201444525348059',
'order_status' => '1',
'order_title' => '住宿酒店',
'mer_code' => 'W00000000001381',
'device_no' => 'CBC92E5GTL000083',
'order_time' => '2020-04-10 12:01:44',
'trade_time' => '2020-04-10 12:01:47',
'gateway_mer_order_id' => '2020041012014445269',
]);
$this->assertSame('A31998F2E0549E0A80B2A4B3A0473784', $sign);
}
/** 发起在线支付:锁定账单、创建支付单、上送网关参数正确、openid 绑定到门店 */
public function test_create_online_payment_success(): void
{
$this->fakeGatewaySuccess();
$store = StoreModel::factory()->create();
$bill1 = $this->makeBill($store, '100.00');
$bill2 = $this->makeBill($store, '50.50');
$this->actingAsMiniStore($store);
$response = $this->postJson('/mini/payment/online', [
'bill_ids' => [$bill1->id, $bill2->id],
'code' => 'wx-login-code',
])->assertJsonPath('success', true);
$paymentNo = $response->json('data.payment_no');
$this->assertSame('150.50', $response->json('data.amount'));
$this->assertSame('WP202608270001', $response->json('data.pay_params.order_id'));
$payment = PaymentModel::where('payment_no', $paymentNo)->first();
$this->assertSame(PaymentModel::TYPE_ONLINE, $payment->pay_type);
$this->assertSame(PaymentModel::METHOD_WANGPU, $payment->pay_method);
$this->assertSame(PaymentModel::STATUS_PENDING, $payment->status);
$this->assertSame('oOpenidTest001', $payment->openid);
$this->assertSame('WP202608270001', $payment->order_id);
// 账单锁定 + openid 绑定门店
$this->assertSame($payment->id, $bill1->fresh()->payment_id);
$this->assertSame($payment->id, $bill2->fresh()->payment_id);
$this->assertSame('oOpenidTest001', $store->fresh()->openid);
// 上送网关的报文:商户订单号=支付单号、金额、openid、带签名
Http::assertSent(static function ($request) use ($paymentNo) {
$body = $request->data();
return str_contains($request->url(), '/industrial/payment/order')
&& ($body['mer_order_id'] ?? '') === $paymentNo
&& ($body['order_amt'] ?? '') === '150.50'
&& ($body['open_id'] ?? '') === 'oOpenidTest001'
&& ($body['sub_appid'] ?? '') === 'wx-mini-test'
&& ! empty($body['sign'])
&& ! empty($body['notifyurl']);
});
}
/** 发起支付校验:缺 code / 非本店账单 / 已支付账单 / 锁定中账单 均拒绝 */
public function test_create_online_payment_validation(): void
{
$this->fakeGatewaySuccess();
$store = StoreModel::factory()->create();
$other = StoreModel::factory()->create();
$this->actingAsMiniStore($store);
// 缺 code
$this->postJson('/mini/payment/online', ['bill_ids' => [1]])->assertJsonPath('success', false);
// 非本店账单
$otherBill = $this->makeBill($other, '10.00');
$this->postJson('/mini/payment/online', ['bill_ids' => [$otherBill->id], 'code' => 'c'])
->assertJsonPath('success', false);
// 已支付账单
$paidBill = $this->makeBill($store, '10.00', ['status' => BillModel::STATUS_PAID]);
$this->postJson('/mini/payment/online', ['bill_ids' => [$paidBill->id], 'code' => 'c'])
->assertJsonPath('success', false);
// 锁定中账单(已在其他支付单)
$lockedBill = $this->makeBill($store, '10.00');
$this->makeOnlinePayment($store, '10.00', $lockedBill);
$this->postJson('/mini/payment/online', ['bill_ids' => [$lockedBill->id], 'code' => 'c'])
->assertJsonPath('success', false);
// 均未产生新的待支付在线支付单(除锁定用那笔)
$this->assertSame(1, PaymentModel::where('pay_type', PaymentModel::TYPE_ONLINE)->count());
}
/** 微信 code2session 失败:报错且不产生支付单 */
public function test_create_fails_when_code2session_fails(): void
{
Http::fake([
'https://api.weixin.qq.com/*' => Http::response(['errcode' => 40029, 'errmsg' => 'invalid code'], 200),
]);
$store = StoreModel::factory()->create();
$bill = $this->makeBill($store, '20.00');
$this->actingAsMiniStore($store);
$this->postJson('/mini/payment/online', ['bill_ids' => [$bill->id], 'code' => 'bad-code'])
->assertJsonPath('success', false);
$this->assertSame(0, PaymentModel::count());
$this->assertSame(0, $bill->fresh()->payment_id);
}
/** 网关下单失败:支付单作废(置失败)并释放账单,可重新发起 */
public function test_create_gateway_failure_releases_bills(): void
{
Http::fake([
'https://api.weixin.qq.com/*' => Http::response(['openid' => 'oOpenidTest001'], 200),
'https://wangpu.test/*' => Http::response(['code' => '9999', 'msg' => '商户号不存在'], 200),
]);
$store = StoreModel::factory()->create();
$bill = $this->makeBill($store, '20.00');
$this->actingAsMiniStore($store);
$this->postJson('/mini/payment/online', ['bill_ids' => [$bill->id], 'code' => 'c'])
->assertJsonPath('success', false);
$payment = PaymentModel::first();
$this->assertSame(PaymentModel::STATUS_REJECTED, $payment->status);
$this->assertSame(0, $bill->fresh()->payment_id, '账单释放可重新付款');
}
/** 支付成功通知:验签通过 → 幂等结账(账单置已支付 + 累加门店总采购金额 + 通知门店) */
public function test_notify_settles_payment(): void
{
$store = StoreModel::factory()->create();
$bill1 = $this->makeBill($store, '100.00');
$bill2 = $this->makeBill($store, '50.00');
$payment = $this->makeOnlinePayment($store, '150.00', $bill1, $bill2);
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment))
->assertJsonPath('code', '00');
$payment->refresh();
$this->assertSame(PaymentModel::STATUS_APPROVED, $payment->status);
$this->assertSame('T20260827001', $payment->trade_no);
$this->assertSame('WP202608270001', $payment->order_id);
$this->assertSame('2026-08-27 10:00:00', (string) $payment->paid_at);
foreach ([$bill1, $bill2] as $bill) {
$bill->refresh();
$this->assertSame(BillModel::STATUS_PAID, $bill->status);
$this->assertStringContainsString($payment->payment_no, (string) $bill->pay_remark);
}
$this->assertSame('150.00', (string) $store->fresh()->total_purchase_amount);
// 门店收到支付成功通知
$this->assertTrue(
NoticeModel::where('store_id', $store->id)->where('title', '账单支付成功')->exists()
);
// 重复通知幂等:仍应答成功,金额不重复累加
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment))
->assertJsonPath('code', '00');
$this->assertSame('150.00', (string) $store->fresh()->total_purchase_amount);
$this->assertSame(1, NoticeModel::where('store_id', $store->id)->count());
}
/** 通知验签失败 / 金额不一致 / 订单号不存在 / 非支付成功状态:应答失败且不结账 */
public function test_notify_rejects_invalid_messages(): void
{
$store = StoreModel::factory()->create();
$bill = $this->makeBill($store, '100.00');
$payment = $this->makeOnlinePayment($store, '100.00', $bill);
// 验签失败
$badSign = $this->signedNotifyParams($payment);
$badSign['sign'] = 'INVALIDSIGN';
$this->postJson('/mini/payment/notify', $badSign)->assertJsonPath('code', '01');
// 金额不一致(防篡改)
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_amt' => '99.99']))
->assertJsonPath('code', '01');
// 订单号不存在
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['mer_order_id' => 'ZF000000000000']))
->assertJsonPath('code', '01');
// 非支付成功状态
$this->postJson('/mini/payment/notify', $this->signedNotifyParams($payment, ['order_status' => '0']))
->assertJsonPath('code', '01');
// 均未结账
$this->assertSame(PaymentModel::STATUS_PENDING, $payment->fresh()->status);
$this->assertSame(BillModel::STATUS_UNPAID, $bill->fresh()->status);
$this->assertSame('0.00', (string) $store->fresh()->total_purchase_amount);
}
/** 主动查询:网关已支付则同步结账;未支付保持待支付 */
public function test_query_syncs_gateway_status(): void
{
$store = StoreModel::factory()->create();
$bill = $this->makeBill($store, '80.00');
$payment = $this->makeOnlinePayment($store, '80.00', $bill);
// 第 1 次查询网关未支付,第 2 次已支付(fake 回调按调用次数返回,避免重复注册被先注册的 stub 拦截)
$queryCount = 0;
Http::fake([
'https://wangpu.test/industrial/query/order' => function () use (&$queryCount, $payment) {
$queryCount++;
$data = $queryCount === 1
? ['order_status' => 0, 'mer_order_id' => $payment->payment_no, 'order_amt' => '80.00']
: [
'order_status' => 1,
'mer_order_id' => $payment->payment_no,
'order_amt' => '80.00',
'trade_no' => 'T20260827002',
'order_id' => 'WP202608270002',
'trade_time' => '2026-08-27 11:00:00',
];
return Http::response(['code' => '0000', 'msg' => '调用成功', 'data' => $data], 200);
},
]);
// 场景一:网关未支付
$this->actingAsMiniStore($store);
$this->getJson("/mini/payment/online/{$payment->payment_no}/query")
->assertJsonPath('success', true)
->assertJsonPath('data.status', PaymentModel::STATUS_PENDING);
$this->assertSame(BillModel::STATUS_UNPAID, $bill->fresh()->status);
// 场景二:网关已支付 → 查询即结账
$this->getJson("/mini/payment/online/{$payment->payment_no}/query")
->assertJsonPath('success', true)
->assertJsonPath('data.status', PaymentModel::STATUS_APPROVED)
->assertJsonPath('data.trade_no', 'T20260827002');
$this->assertSame(BillModel::STATUS_PAID, $bill->fresh()->status);
$this->assertSame('80.00', (string) $store->fresh()->total_purchase_amount);
// 已结账后重复查询不再请求网关(本地直接返回)
$this->getJson("/mini/payment/online/{$payment->payment_no}/query")
->assertJsonPath('success', true)
->assertJsonPath('data.status', PaymentModel::STATUS_APPROVED);
$this->assertSame(2, $queryCount, '已结账后不再请求网关');
$this->assertSame('80.00', (string) $store->fresh()->total_purchase_amount);
}
/** 查询接口隔离:他人支付单不可见 */
public function test_query_rejects_other_store_payment(): void
{
$store = StoreModel::factory()->create();
$other = StoreModel::factory()->create();
$bill = $this->makeBill($other, '10.00');
$payment = $this->makeOnlinePayment($other, '10.00', $bill);
$this->actingAsMiniStore($store);
$this->getJson("/mini/payment/online/{$payment->payment_no}/query")
->assertJsonPath('success', false);
}
/** 现有线下凭证支付流程不受影响:默认 pay_type=1 */
public function test_offline_payment_flow_unaffected(): void
{
$store = StoreModel::factory()->create();
$bill = $this->makeBill($store, '30.00');
$this->actingAsMiniStore($store);
$this->postJson('/mini/payment', [
'bill_ids' => [$bill->id],
'pay_method' => PaymentModel::METHOD_BANK,
'voucher_ids' => [1],
])->assertJsonPath('success', true);
$payment = PaymentModel::first();
$this->assertSame(PaymentModel::TYPE_OFFLINE, $payment->pay_type, '线下凭证支付默认 pay_type=1');
$this->assertSame($payment->id, $bill->fresh()->payment_id);
}
}