在线支付
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
<?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 test(MD5 升序拼接 + 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user