416 lines
16 KiB
PHP
416 lines
16 KiB
PHP
<?php
|
|
|
|
namespace Plugins\Sirsoft\PayNhnkcp\Tests\Feature\Controllers;
|
|
|
|
use App\Models\User;
|
|
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
|
|
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
|
|
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
|
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
|
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
|
use Modules\Sirsoft\Ecommerce\Models\Order;
|
|
use Plugins\Sirsoft\PayNhnkcp\Services\KcpSoapService;
|
|
use Plugins\Sirsoft\PayNhnkcp\Tests\PluginTestCase;
|
|
|
|
class MobileApprovalControllerTest extends PluginTestCase
|
|
{
|
|
private const APPROVAL_KEY_ENDPOINT = '/api/plugins/sirsoft-pay_nhnkcp/mobile/approval-key';
|
|
|
|
private const TEST_PAY_URL = 'https://testpay.kcp.co.kr/php/mobile/mc_pay_form.php';
|
|
|
|
// ===== 헬퍼 =====
|
|
|
|
/**
|
|
* @param array{taxable?: int, vat?: int, taxFree?: int} $tax
|
|
*/
|
|
private function createOrder(int $totalAmount = 10000, array $tax = []): Order
|
|
{
|
|
$taxable = $tax['taxable'] ?? $totalAmount;
|
|
$vat = $tax['vat'] ?? (int) round($taxable * 10 / 110);
|
|
$taxFree = $tax['taxFree'] ?? 0;
|
|
|
|
$user = User::factory()->create();
|
|
$order = OrderFactory::new()->create([
|
|
'user_id' => $user->id,
|
|
'order_number' => 'ORD-MOB-' . random_int(10000, 99999),
|
|
'order_status' => OrderStatusEnum::PENDING_ORDER,
|
|
'subtotal_amount' => $totalAmount,
|
|
'total_amount' => $totalAmount,
|
|
'total_due_amount' => $totalAmount,
|
|
'total_paid_amount' => 0,
|
|
'total_tax_amount' => $taxable,
|
|
'total_vat_amount' => $vat,
|
|
'total_tax_free_amount'=> $taxFree,
|
|
'currency' => 'KRW',
|
|
'currency_snapshot' => self::krwCurrencySnapshot(),
|
|
]);
|
|
|
|
OrderPaymentFactory::new()->create([
|
|
'order_id' => $order->id,
|
|
'payment_status' => PaymentStatusEnum::READY,
|
|
'payment_method' => PaymentMethodEnum::CARD,
|
|
'pg_provider' => 'nhnkcp',
|
|
]);
|
|
|
|
return $order;
|
|
}
|
|
|
|
private static function krwCurrencySnapshot(): array
|
|
{
|
|
return [
|
|
'base_currency' => 'KRW',
|
|
'order_currency' => 'KRW',
|
|
'base_unit' => 1,
|
|
'exchange_rates' => [
|
|
'KRW' => [
|
|
'rate' => 1,
|
|
'rounding_unit' => '1',
|
|
'rounding_method' => 'round',
|
|
'decimal_places' => 0,
|
|
'base_unit' => 1,
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
private function mockSoapService(string $approvalKey = 'TEST_APPROVAL_KEY'): void
|
|
{
|
|
$mock = $this->createMock(KcpSoapService::class);
|
|
$mock->method('getApprovalKey')->willReturn([
|
|
'approval_key' => $approvalKey,
|
|
'pay_url' => self::TEST_PAY_URL,
|
|
]);
|
|
$mock->method('getSiteCd')->willReturn('T0000');
|
|
$mock->method('getEscrowSiteCd')->willReturn('T0000');
|
|
$this->app->instance(KcpSoapService::class, $mock);
|
|
}
|
|
|
|
private function expectSoapServiceNotCalled(): void
|
|
{
|
|
$mock = $this->createMock(KcpSoapService::class);
|
|
$mock->expects($this->never())->method('getApprovalKey');
|
|
$this->app->instance(KcpSoapService::class, $mock);
|
|
}
|
|
|
|
private function mockPluginSettings(): void
|
|
{
|
|
$mock = $this->createMock(\App\Services\PluginSettingsService::class);
|
|
$mock->method('get')->willReturn([
|
|
'is_test_mode' => true,
|
|
'test_site_cd' => 'T0000',
|
|
'test_site_key' => 'TEST_KEY',
|
|
]);
|
|
$this->app->instance(\App\Services\PluginSettingsService::class, $mock);
|
|
}
|
|
|
|
private function approvalKeyPayload(Order $order, array $overrides = []): array
|
|
{
|
|
return array_merge([
|
|
'order_number' => $order->order_number,
|
|
'amount' => (int) $order->total_due_amount,
|
|
'good_name' => '테스트 상품',
|
|
'pay_method' => 'card',
|
|
'buyr_name' => '홍길동',
|
|
'buyr_mail' => 'test@test.com',
|
|
'buyr_tel1' => '01012345678',
|
|
'ret_url' => 'https://example.com/callback',
|
|
], $overrides);
|
|
}
|
|
|
|
// ===== 기본 승인키 획득 =====
|
|
|
|
public function test_get_approval_key_returns_pay_url_and_fields(): void
|
|
{
|
|
$order = $this->createOrder(10000);
|
|
$this->mockSoapService('APPROVAL_001');
|
|
$this->mockPluginSettings();
|
|
$user = $order->user;
|
|
|
|
$response = $this->actingAs($user)
|
|
->postJson(self::APPROVAL_KEY_ENDPOINT, $this->approvalKeyPayload($order));
|
|
|
|
$response->assertOk()
|
|
->assertJsonPath('success', true)
|
|
->assertJsonPath('data.pay_url', self::TEST_PAY_URL)
|
|
->assertJsonPath('data.fields.approval_key', 'APPROVAL_001')
|
|
->assertJsonPath('data.fields.ordr_idxx', $order->order_number)
|
|
->assertJsonPath('data.fields.good_mny', (string) (int) $order->total_due_amount);
|
|
}
|
|
|
|
public function test_get_approval_key_restores_retryable_cancelled_order(): void
|
|
{
|
|
$order = $this->createOrder(10000);
|
|
$order->update([
|
|
'order_status' => OrderStatusEnum::CANCELLED,
|
|
'order_meta' => [
|
|
'payment_failure_code' => 'USER_CANCEL',
|
|
'payment_failure_message' => '사용자 취소',
|
|
'payment_failed_at' => now()->subMinute()->toIso8601String(),
|
|
],
|
|
]);
|
|
$order->payment->update([
|
|
'payment_status' => PaymentStatusEnum::CANCELLED,
|
|
'cancelled_at' => now()->subMinute(),
|
|
'paid_at' => null,
|
|
'transaction_id' => null,
|
|
'card_approval_number' => null,
|
|
'payment_meta' => [
|
|
'failure_source' => 'nhnkcp',
|
|
'failure_code' => 'USER_CANCEL',
|
|
'failure_message' => '사용자 취소',
|
|
'failure_stage' => 'window_closed',
|
|
'failed_at' => now()->subMinute()->toIso8601String(),
|
|
],
|
|
]);
|
|
|
|
$this->mockSoapService('APPROVAL_RETRY');
|
|
$this->mockPluginSettings();
|
|
|
|
$response = $this->actingAs($order->user)
|
|
->postJson(self::APPROVAL_KEY_ENDPOINT, $this->approvalKeyPayload($order));
|
|
|
|
$response->assertOk()
|
|
->assertJsonPath('data.fields.approval_key', 'APPROVAL_RETRY');
|
|
|
|
$order->refresh();
|
|
$payment = $order->payment;
|
|
$payment->refresh();
|
|
|
|
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
|
|
$this->assertEquals(PaymentStatusEnum::READY, $payment->payment_status);
|
|
$this->assertNull($payment->cancelled_at);
|
|
}
|
|
|
|
public function test_get_approval_key_returns_422_on_soap_failure(): void
|
|
{
|
|
$order = $this->createOrder(10000);
|
|
$this->mockPluginSettings();
|
|
|
|
$mock = $this->createMock(KcpSoapService::class);
|
|
$mock->method('getApprovalKey')
|
|
->willThrowException(new \Plugins\Sirsoft\PayNhnkcp\Exceptions\NhnKcpApiException('KCP SOAP 오류'));
|
|
$mock->method('getSiteCd')->willReturn('T0000');
|
|
$this->app->instance(KcpSoapService::class, $mock);
|
|
|
|
$response = $this->actingAs($order->user)
|
|
->postJson(self::APPROVAL_KEY_ENDPOINT, $this->approvalKeyPayload($order));
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonPath('success', false);
|
|
}
|
|
|
|
public function test_get_approval_key_requires_authentication(): void
|
|
{
|
|
$order = $this->createOrder(10000);
|
|
$this->mockSoapService();
|
|
$this->mockPluginSettings();
|
|
|
|
$response = $this->postJson(self::APPROVAL_KEY_ENDPOINT, $this->approvalKeyPayload($order));
|
|
|
|
$response->assertUnauthorized();
|
|
}
|
|
|
|
public function test_get_approval_key_rejects_other_users_order(): void
|
|
{
|
|
$order = $this->createOrder(10000);
|
|
$otherUser = User::factory()->create();
|
|
$this->expectSoapServiceNotCalled();
|
|
$this->mockPluginSettings();
|
|
|
|
$response = $this->actingAs($otherUser)
|
|
->postJson(self::APPROVAL_KEY_ENDPOINT, $this->approvalKeyPayload($order));
|
|
|
|
$response->assertForbidden()
|
|
->assertJsonPath('success', false);
|
|
}
|
|
|
|
public function test_get_approval_key_rejects_amount_mismatch(): void
|
|
{
|
|
$order = $this->createOrder(10000);
|
|
$this->expectSoapServiceNotCalled();
|
|
$this->mockPluginSettings();
|
|
|
|
$response = $this->actingAs($order->user)
|
|
->postJson(self::APPROVAL_KEY_ENDPOINT, $this->approvalKeyPayload($order, ['amount' => 9900]));
|
|
|
|
$response->assertUnprocessable()
|
|
->assertJsonPath('success', false);
|
|
}
|
|
|
|
public function test_get_approval_key_rejects_non_krw_order(): void
|
|
{
|
|
$order = $this->createOrder(10000);
|
|
$order->currency = 'USD';
|
|
$order->save();
|
|
$this->expectSoapServiceNotCalled();
|
|
$this->mockPluginSettings();
|
|
|
|
$response = $this->actingAs($order->user)
|
|
->postJson(self::APPROVAL_KEY_ENDPOINT, $this->approvalKeyPayload($order, ['currency' => 'USD']));
|
|
|
|
$response->assertUnprocessable()
|
|
->assertJsonPath('success', false);
|
|
}
|
|
|
|
public function test_get_approval_key_validates_required_fields(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$this->mockSoapService();
|
|
$this->mockPluginSettings();
|
|
|
|
$response = $this->actingAs($user)
|
|
->postJson(self::APPROVAL_KEY_ENDPOINT, []);
|
|
|
|
$response->assertUnprocessable();
|
|
}
|
|
|
|
// ===== 간편결제 direct 파라미터 =====
|
|
|
|
public function test_naverpay_easy_pay_includes_direct_field(): void
|
|
{
|
|
$order = $this->createOrder(10000);
|
|
$this->mockSoapService();
|
|
$this->mockPluginSettings();
|
|
|
|
$response = $this->actingAs($order->user)
|
|
->postJson(self::APPROVAL_KEY_ENDPOINT, $this->approvalKeyPayload($order, ['pay_method' => 'nhnkcp_naverpay']));
|
|
|
|
$response->assertOk()
|
|
->assertJsonPath('data.fields.naverpay_direct', 'Y')
|
|
->assertJsonPath('data.fields.param_opt_1', 'nhnkcp_naverpay')
|
|
->assertJsonPath('data.fields.nhnkcp_easy_pay_method', 'nhnkcp_naverpay');
|
|
}
|
|
|
|
public function test_kakaopay_easy_pay_includes_direct_field(): void
|
|
{
|
|
$order = $this->createOrder(10000);
|
|
$this->mockSoapService();
|
|
$this->mockPluginSettings();
|
|
|
|
$response = $this->actingAs($order->user)
|
|
->postJson(self::APPROVAL_KEY_ENDPOINT, $this->approvalKeyPayload($order, ['pay_method' => 'nhnkcp_kakaopay']));
|
|
|
|
$response->assertOk()
|
|
->assertJsonPath('data.fields.kakaopay_direct', 'Y');
|
|
}
|
|
|
|
// ===== 과세/비과세 분할 필드 =====
|
|
|
|
public function test_fully_taxable_order_does_not_include_tax_flag(): void
|
|
{
|
|
// 전액 과세 → 복합과세 필드 불필요. KCP가 내부 계산
|
|
$amount = 11000;
|
|
$vat = (int) round($amount * 10 / 110);
|
|
$order = $this->createOrder($amount, ['taxable' => $amount, 'vat' => $vat, 'taxFree' => 0]);
|
|
$this->mockSoapService();
|
|
$this->mockPluginSettings();
|
|
|
|
$response = $this->actingAs($order->user)
|
|
->postJson(self::APPROVAL_KEY_ENDPOINT, $this->approvalKeyPayload($order));
|
|
|
|
$response->assertOk();
|
|
$fields = $response->json('data.fields');
|
|
|
|
$this->assertArrayNotHasKey('tax_flag', $fields, '전액 과세 시 tax_flag 포함 불필요');
|
|
$this->assertArrayNotHasKey('comm_tax_mny', $fields);
|
|
$this->assertArrayNotHasKey('comm_free_mny', $fields);
|
|
}
|
|
|
|
public function test_fully_tax_free_order_includes_comm_free_mny(): void
|
|
{
|
|
// 10,000원 전액 비과세 (면세 상품)
|
|
$amount = 10000;
|
|
$order = $this->createOrder($amount, ['taxable' => 0, 'vat' => 0, 'taxFree' => $amount]);
|
|
$this->mockSoapService();
|
|
$this->mockPluginSettings();
|
|
|
|
$response = $this->actingAs($order->user)
|
|
->postJson(self::APPROVAL_KEY_ENDPOINT, $this->approvalKeyPayload($order));
|
|
|
|
$response->assertOk();
|
|
$fields = $response->json('data.fields');
|
|
|
|
$this->assertArrayHasKey('tax_flag', $fields);
|
|
$this->assertEquals('TG03', $fields['tax_flag']);
|
|
$this->assertEquals('0', $fields['comm_tax_mny'], '공급가액은 0');
|
|
$this->assertEquals('0', $fields['comm_vat_mny'], '부가세는 0');
|
|
$this->assertEquals((string) $amount, $fields['comm_free_mny'], '비과세 금액 일치');
|
|
}
|
|
|
|
public function test_mixed_tax_order_includes_all_tax_fields(): void
|
|
{
|
|
// 21,000원 = 과세 11,000(공급가 10,000 + 부가세 1,000) + 비과세 10,000
|
|
$taxable = 11000;
|
|
$taxFree = 10000;
|
|
$total = $taxable + $taxFree;
|
|
$vat = (int) round($taxable * 10 / 110); // 1,000
|
|
$supplyAmt = $taxable - $vat; // 10,000
|
|
|
|
$order = $this->createOrder($total, ['taxable' => $taxable, 'vat' => $vat, 'taxFree' => $taxFree]);
|
|
$this->mockSoapService();
|
|
$this->mockPluginSettings();
|
|
|
|
$response = $this->actingAs($order->user)
|
|
->postJson(self::APPROVAL_KEY_ENDPOINT, $this->approvalKeyPayload($order));
|
|
|
|
$response->assertOk();
|
|
$fields = $response->json('data.fields');
|
|
|
|
$this->assertEquals('TG03', $fields['tax_flag']);
|
|
$this->assertEquals((string) $supplyAmt, $fields['comm_tax_mny'], '공급가액 일치');
|
|
$this->assertEquals((string) $vat, $fields['comm_vat_mny'], '부가세 일치');
|
|
$this->assertEquals((string) $taxFree, $fields['comm_free_mny'], '비과세 금액 일치');
|
|
|
|
// 금액 검증: good_mny = comm_tax_mny + comm_vat_mny + comm_free_mny
|
|
$this->assertEquals(
|
|
(string) $total,
|
|
$fields['good_mny'],
|
|
'good_mny = 공급가액 + 부가세 + 비과세'
|
|
);
|
|
}
|
|
|
|
public function test_mixed_tax_fields_are_rebalanced_to_payment_amount(): void
|
|
{
|
|
// 원 상품 과세/비과세 합계 21,000원에서 쿠폰/마일리지 등으로 실결제액이 19,000원이 된 상황.
|
|
$taxable = 11000;
|
|
$taxFree = 10000;
|
|
$paymentAmount = 19000;
|
|
$vat = 1000;
|
|
|
|
$order = $this->createOrder($paymentAmount, ['taxable' => $taxable, 'vat' => $vat, 'taxFree' => $taxFree]);
|
|
$this->mockSoapService();
|
|
$this->mockPluginSettings();
|
|
|
|
$response = $this->actingAs($order->user)
|
|
->postJson(self::APPROVAL_KEY_ENDPOINT, $this->approvalKeyPayload($order));
|
|
|
|
$response->assertOk();
|
|
$fields = $response->json('data.fields');
|
|
|
|
$this->assertEquals('TG03', $fields['tax_flag']);
|
|
$this->assertEquals('8182', $fields['comm_tax_mny']);
|
|
$this->assertEquals('818', $fields['comm_vat_mny']);
|
|
$this->assertEquals('10000', $fields['comm_free_mny']);
|
|
$this->assertSame(
|
|
(int) $fields['good_mny'],
|
|
(int) $fields['comm_tax_mny'] + (int) $fields['comm_vat_mny'] + (int) $fields['comm_free_mny'],
|
|
'KCP 복합과세 분할합은 good_mny와 반드시 일치해야 함'
|
|
);
|
|
}
|
|
|
|
public function test_get_approval_key_rejects_missing_order(): void
|
|
{
|
|
$order = $this->createOrder(10000);
|
|
$this->expectSoapServiceNotCalled();
|
|
$this->mockPluginSettings();
|
|
|
|
$payload = $this->approvalKeyPayload($order, ['order_number' => 'NON-EXISTENT-ORDER']);
|
|
|
|
$response = $this->actingAs($order->user)
|
|
->postJson(self::APPROVAL_KEY_ENDPOINT, $payload);
|
|
|
|
$response->assertNotFound()
|
|
->assertJsonPath('success', false);
|
|
}
|
|
}
|