fix(payment): KG 이니시스 결제 콜백 안정성 보강
KG 이니시스 결제에서 통화 스냅샷 이상 시 서버 오류로 새지 않도록 검증 실패/재통보 가능한 응답으로 처리하고, 주문 단위 콜백 락으로 동시 완료 콜백의 중복 후속 처리를 줄입니다. 백엔드 테스트의 통화 스냅샷 fixture도 최신 계약에 맞춰 복구합니다. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
1 parent
d18aaab1fd
commit
8429d8ada5
18 files changed
+472
-19
No files matched your search
@@ -30,6 +30,8 @@
|
||||
- 표준/모바일 결제 서명 API에서 현재 모드의 MID와 서명키가 준비되지 않은 경우 서명을 생성하지 않고 중단하도록 보강했습니다.
|
||||
- PC/모바일/CBT 결제 콜백에서 로컬 결제완료 저장 후 예외가 발생한 경우 PG 취소·환불 보상 호출을 건너뛰도록 보강했습니다. 로컬 주문은 결제완료인데 PG 거래만 취소되는 상태 불일치를 방지합니다.
|
||||
- 일본 편의점결제(CBT CVS) 만료 처리와 통보 이력 저장 시 결제 row를 잠근 뒤 최신 상태를 다시 확인하도록 보강해 입금통보와 관리자 만료 처리의 동시성 충돌 가능성을 줄였습니다.
|
||||
- 통화 스냅샷의 환율 설정이 비정상인 주문은 표준/모바일/CBT/가상계좌 결제 흐름에서 서버 오류 대신 검증 실패 또는 재통보 가능한 응답으로 처리하도록 보강했습니다.
|
||||
- PC/모바일/CBT/가상계좌 결제 완료 콜백을 주문 단위로 직렬화해 완전 동시 콜백에서 결제완료 후속 처리 훅이 중복 실행될 가능성을 줄였습니다.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Concerns;
|
||||
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
trait SerializesPaymentCallbacks
|
||||
{
|
||||
protected function acquireOrderCallbackLock(string $context, string $orderNumber): mixed
|
||||
{
|
||||
$key = $this->callbackLockKey($context, $orderNumber);
|
||||
$lock = Cache::lock($key, $this->callbackLockTtlSeconds());
|
||||
|
||||
try {
|
||||
$lock->block($this->callbackLockWaitSeconds());
|
||||
} catch (LockTimeoutException $e) {
|
||||
Log::warning('KG Inicis: callback lock timeout', [
|
||||
'context' => $context,
|
||||
'order_number' => $orderNumber,
|
||||
'lock_key' => $key,
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $lock;
|
||||
}
|
||||
|
||||
protected function releaseOrderCallbackLock(mixed $lock): void
|
||||
{
|
||||
if (is_object($lock) && method_exists($lock, 'release')) {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
protected function callbackLockKey(string $context, string $orderNumber): string
|
||||
{
|
||||
return 'sirsoft-pay_kginicis:callback:'
|
||||
. $context
|
||||
. ':'
|
||||
. sha1(trim($orderNumber));
|
||||
}
|
||||
|
||||
protected function callbackLockTtlSeconds(): int
|
||||
{
|
||||
return 30;
|
||||
}
|
||||
|
||||
protected function callbackLockWaitSeconds(): int
|
||||
{
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Concerns;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
|
||||
@@ -24,6 +26,33 @@ trait ValidatesCbtOrderContext
|
||||
return $this->expectedPaymentPrice($order);
|
||||
}
|
||||
|
||||
protected function resolveExpectedPaymentPriceOrNull(Order $order, string $context, array $logContext = []): ?int
|
||||
{
|
||||
try {
|
||||
return $this->expectedPaymentPrice($order);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->logInvalidPaymentCurrency($order, $e, $context, $logContext);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function logInvalidPaymentCurrency(
|
||||
Order $order,
|
||||
InvalidArgumentException $e,
|
||||
string $context,
|
||||
array $logContext = [],
|
||||
): void {
|
||||
Log::error('KG Inicis: payment currency is not chargeable', array_merge([
|
||||
'context' => $context,
|
||||
'order_id' => $order->id,
|
||||
'order_number' => $order->order_number,
|
||||
'currency' => $order->currency,
|
||||
'currency_snapshot' => $order->currency_snapshot,
|
||||
'error' => $e->getMessage(),
|
||||
], $logContext));
|
||||
}
|
||||
|
||||
protected function requestMatchesOrderBuyer(Request $request, Order $order): bool
|
||||
{
|
||||
/** @var OrderAddress|null $address */
|
||||
|
||||
@@ -10,11 +10,12 @@ use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\IssuesReceiptCookie;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\PreventsReplayCallback;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks;
|
||||
use Plugins\Sirsoft\PayKginicis\Http\Requests\CbtCallbackRequest;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext;
|
||||
use Plugins\Sirsoft\PayKginicis\Services\CbtReconciliationService;
|
||||
use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService;
|
||||
|
||||
@@ -31,6 +32,8 @@ class CbtCallbackController
|
||||
{
|
||||
use IssuesReceiptCookie;
|
||||
use PreventsReplayCallback;
|
||||
use SerializesPaymentCallbacks;
|
||||
use ValidatesCbtOrderContext;
|
||||
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_kginicis';
|
||||
|
||||
@@ -167,6 +170,7 @@ class CbtCallbackController
|
||||
// Approve 성공 후 후속 처리 실패 시 PG 자동 cancel 추적 변수.
|
||||
$approvedTid = null;
|
||||
$approvedAmount = 0;
|
||||
$callbackLock = null;
|
||||
|
||||
try {
|
||||
$order = $this->orderService->findByOrderNumber($oid);
|
||||
@@ -177,6 +181,8 @@ class CbtCallbackController
|
||||
return redirect($this->resolveFailUrl(['error' => 'order_not_found', 'orderId' => $oid]));
|
||||
}
|
||||
|
||||
$callbackLock = $this->acquireOrderCallbackLock('cbtAuthCallback', $oid);
|
||||
|
||||
$this->assertPayableCbtOrder($order);
|
||||
|
||||
$pgResponse = $this->apiService->approveCbtPayment($sid);
|
||||
@@ -221,9 +227,6 @@ class CbtCallbackController
|
||||
}
|
||||
|
||||
$this->assertCbtApproveResponseMatchesOrder($order, $pgResponse, $request, $payMethod);
|
||||
// PG 승인금액이 누락되어 검증 예외가 발생해도 자동환불 대사 레코드에는 결제 통화 청구액을 남긴다
|
||||
// (buildPgPaymentData 와 동일 SSoT — base≠결제 통화에서도 PG 청구 통화와 단위 일치).
|
||||
$approvedAmount = app(CurrencyConversionService::class)->resolveOrderPaymentChargeAmount($order);
|
||||
$approvedAmount = $this->resolveApprovedAmount($pgResponse, $order);
|
||||
$authResponse = $this->sanitizePgResponse($request->except(['_token']), self::CBT_AUTH_RESPONSE_KEYS);
|
||||
$approveResponse = $this->sanitizePgResponse($pgResponse, self::CBT_APPROVE_RESPONSE_KEYS);
|
||||
@@ -293,6 +296,8 @@ class CbtCallbackController
|
||||
);
|
||||
|
||||
return redirect($this->resolveFailUrl(['error' => 'cbt_failed', 'orderId' => $oid]));
|
||||
} finally {
|
||||
$this->releaseOrderCallbackLock($callbackLock);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,8 +401,14 @@ class CbtCallbackController
|
||||
// 결제 청구액 SSoT = 결제 통화(order_currency) 환산액 (buildPgPaymentData 와 동일 기준).
|
||||
// CBT 는 order_currency=JPY 강제이나 base 통화는 다를 수 있어(예: base KRW, 결제 JPY)
|
||||
// total_due_amount(base) 직접 비교 시 PG 승인액(JPY)과 단위가 어긋난다.
|
||||
$expectedAmount = app(CurrencyConversionService::class)->resolveOrderPaymentChargeAmount($order);
|
||||
$pgAmount = $this->firstNonEmptyString($pgResponse, ['amount', 'price', 'bokuApplPrice', 'bokuLocalApplPrice']);
|
||||
$expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'cbt_approve', [
|
||||
'tid' => $pgResponse['tid'] ?? ($pgResponse['transactionId'] ?? null),
|
||||
'received_amount' => $pgAmount,
|
||||
]);
|
||||
if ($expectedAmount === null) {
|
||||
throw new \RuntimeException('KG Inicis CBT payment currency is not chargeable.');
|
||||
}
|
||||
|
||||
// 일본 CBT 승인 응답은 금액 필드가 비어 온다(매뉴얼상 미사용). 금액이 없으면
|
||||
// 주문 결제예정액을 승인액으로 신뢰한다(승인 권위 = resultCode OK + tid).
|
||||
|
||||
@@ -15,6 +15,8 @@ use Plugins\Sirsoft\PayKginicis\Concerns\IssuesReceiptCookie;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\PreventsReplayCallback;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\ResolvesEasyPaySelection;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\SanitizesPgResponse;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext;
|
||||
use Plugins\Sirsoft\PayKginicis\Http\Requests\MobileCallbackRequest;
|
||||
use Plugins\Sirsoft\PayKginicis\Services\KgInicisApiService;
|
||||
|
||||
@@ -33,6 +35,8 @@ class MobileCallbackController
|
||||
use PreventsReplayCallback;
|
||||
use ResolvesEasyPaySelection;
|
||||
use SanitizesPgResponse;
|
||||
use SerializesPaymentCallbacks;
|
||||
use ValidatesCbtOrderContext;
|
||||
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_kginicis';
|
||||
|
||||
@@ -172,6 +176,7 @@ class MobileCallbackController
|
||||
// cancelPayment 를 호출해 PG 잔존 승인을 해제한다 (사용자 환불 보장).
|
||||
$approvedTid = null;
|
||||
$approvedTotPrice = 0;
|
||||
$callbackLock = null;
|
||||
|
||||
try {
|
||||
$order = $this->orderService->findByOrderNumber($moid);
|
||||
@@ -182,6 +187,8 @@ class MobileCallbackController
|
||||
return redirect($this->resolveFailUrl(['error' => 'order_not_found', 'orderId' => $moid]));
|
||||
}
|
||||
|
||||
$callbackLock = $this->acquireOrderCallbackLock('mobileAuthCallback', $moid);
|
||||
|
||||
// 서버 승인 요청: POST P_REQ_URL with P_MID + P_TID
|
||||
$result = $this->apiService->authorizeMobilePayment($reqUrl, $pTid);
|
||||
|
||||
@@ -228,6 +235,21 @@ class MobileCallbackController
|
||||
$approvedTotPrice = $totPrice;
|
||||
$embeddedPgProvider = $this->resolveEmbeddedPgProvider($selectedEasyPayMethod);
|
||||
|
||||
$expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'mobile_auth_callback', [
|
||||
'tid' => $tid,
|
||||
'received_amount' => $totPrice,
|
||||
]);
|
||||
if ($expectedAmount === null) {
|
||||
$this->autoCancelIfApproved($approvedTid, $moid, $approvedTotPrice, 'invalid_payment_currency');
|
||||
|
||||
return redirect($this->resolveFailUrl(['error' => 'invalid_payment_currency', 'orderId' => $moid]));
|
||||
}
|
||||
if ($expectedAmount > 0 && $totPrice !== $expectedAmount) {
|
||||
$this->autoCancelIfApproved($approvedTid, $moid, $approvedTotPrice, 'amount_mismatch');
|
||||
|
||||
return redirect($this->resolveFailUrl(['error' => 'amount_mismatch', 'orderId' => $moid]));
|
||||
}
|
||||
|
||||
Log::info('KG Inicis mobile: completing card payment', [
|
||||
'P_OID' => $moid,
|
||||
'P_TID' => $tid,
|
||||
@@ -298,6 +320,8 @@ class MobileCallbackController
|
||||
'message' => $e->getMessage(),
|
||||
'orderId' => $moid,
|
||||
]));
|
||||
} finally {
|
||||
$this->releaseOrderCallbackLock($callbackLock);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-1
@@ -107,7 +107,17 @@ class MobileSignatureController
|
||||
], 403);
|
||||
}
|
||||
|
||||
if ($price !== $this->expectedPaymentPrice($order)) {
|
||||
$expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'mobile_signature', [
|
||||
'received_amount' => $price,
|
||||
]);
|
||||
if ($expectedPrice === null) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Payment currency is not chargeable.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($price !== $expectedPrice) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Payment amount does not match the order amount.',
|
||||
|
||||
+45
-2
@@ -16,12 +16,13 @@ use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
|
||||
use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\IssuesReceiptCookie;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\PreventsReplayCallback;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\ResolvesEasyPaySelection;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\SanitizesPgResponse;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext;
|
||||
use Plugins\Sirsoft\PayKginicis\Http\Requests\AuthCallbackRequest;
|
||||
use Plugins\Sirsoft\PayKginicis\Http\Requests\MobileVbankNotifyRequest;
|
||||
use Plugins\Sirsoft\PayKginicis\Http\Requests\VbankNotifyRequest;
|
||||
@@ -40,6 +41,8 @@ class PaymentCallbackController
|
||||
use PreventsReplayCallback;
|
||||
use ResolvesEasyPaySelection;
|
||||
use SanitizesPgResponse;
|
||||
use SerializesPaymentCallbacks;
|
||||
use ValidatesCbtOrderContext;
|
||||
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_kginicis';
|
||||
|
||||
@@ -223,6 +226,7 @@ class PaymentCallbackController
|
||||
$authUrl = $receivedAuthUrl;
|
||||
$netCancelUrl = $this->apiService->resolveIdcNetCancelUrl($idcName);
|
||||
$tid = '';
|
||||
$callbackLock = null;
|
||||
|
||||
try {
|
||||
$order = $this->orderService->findByOrderNumber($moid);
|
||||
@@ -233,6 +237,8 @@ class PaymentCallbackController
|
||||
return redirect($this->resolveFailUrl(['error' => 'order_not_found', 'orderId' => $moid]));
|
||||
}
|
||||
|
||||
$callbackLock = $this->acquireOrderCallbackLock('authCallback', $moid);
|
||||
|
||||
HookManager::doAction('sirsoft-pay_kginicis.payment.before_authorize', $order, $validated);
|
||||
|
||||
$pgResponse = $this->apiService->authorizePayment($authUrl, $authToken);
|
||||
@@ -280,6 +286,21 @@ class PaymentCallbackController
|
||||
$totPrice = (int) ($pgResponse['TotPrice'] ?? $pgResponse['totPrice'] ?? 0);
|
||||
}
|
||||
|
||||
$expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'auth_callback', [
|
||||
'tid' => $tid,
|
||||
'received_amount' => $totPrice,
|
||||
]);
|
||||
if ($expectedAmount === null) {
|
||||
$this->apiService->sendNetCancel($netCancelUrl, $authToken);
|
||||
|
||||
return redirect($this->resolveFailUrl(['error' => 'invalid_payment_currency', 'orderId' => $moid]));
|
||||
}
|
||||
if ($expectedAmount > 0 && $totPrice !== $expectedAmount) {
|
||||
$this->apiService->sendNetCancel($netCancelUrl, $authToken);
|
||||
|
||||
return redirect($this->resolveFailUrl(['error' => 'amount_mismatch', 'orderId' => $moid]));
|
||||
}
|
||||
|
||||
$embeddedPgProvider = $this->resolveEmbeddedPgProvider($selectedEasyPayMethod);
|
||||
|
||||
Log::info('KG Inicis: completing card payment', [
|
||||
@@ -352,6 +373,8 @@ class PaymentCallbackController
|
||||
'message' => $e->getMessage(),
|
||||
'orderId' => $moid,
|
||||
]));
|
||||
} finally {
|
||||
$this->releaseOrderCallbackLock($callbackLock);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,6 +399,8 @@ class PaymentCallbackController
|
||||
'bank' => $validated['nm_inputbank'] ?? null,
|
||||
]);
|
||||
|
||||
$callbackLock = null;
|
||||
|
||||
try {
|
||||
$order = $this->orderService->findByOrderNumber($moid);
|
||||
|
||||
@@ -385,6 +410,8 @@ class PaymentCallbackController
|
||||
return response('FAIL', 200)->header('Content-Type', 'text/plain');
|
||||
}
|
||||
|
||||
$callbackLock = $this->acquireOrderCallbackLock('vbankNotify', $moid);
|
||||
|
||||
if ($this->wasAlreadyPaid($tid)) {
|
||||
$this->logReplayDetected($tid, $moid, 'PC vbankNotify');
|
||||
|
||||
@@ -430,6 +457,8 @@ class PaymentCallbackController
|
||||
]);
|
||||
|
||||
return response('FAIL', 200)->header('Content-Type', 'text/plain');
|
||||
} finally {
|
||||
$this->releaseOrderCallbackLock($callbackLock);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,6 +496,8 @@ class PaymentCallbackController
|
||||
'bank' => $validated['P_FN_NM'] ?? null,
|
||||
]);
|
||||
|
||||
$callbackLock = null;
|
||||
|
||||
try {
|
||||
$order = $this->orderService->findByOrderNumber($moid);
|
||||
|
||||
@@ -476,6 +507,8 @@ class PaymentCallbackController
|
||||
return response('FAIL', 200)->header('Content-Type', 'text/plain');
|
||||
}
|
||||
|
||||
$callbackLock = $this->acquireOrderCallbackLock('mobileVbankNotify', $moid);
|
||||
|
||||
if ($this->wasAlreadyPaid($tid)) {
|
||||
$this->logReplayDetected($tid, $moid, 'mobile vbankNotify');
|
||||
|
||||
@@ -521,6 +554,8 @@ class PaymentCallbackController
|
||||
]);
|
||||
|
||||
return response('FAIL', 200)->header('Content-Type', 'text/plain');
|
||||
} finally {
|
||||
$this->releaseOrderCallbackLock($callbackLock);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,7 +634,15 @@ class PaymentCallbackController
|
||||
|
||||
// 결제 청구액 SSoT = 결제 통화(order_currency) 환산액 (buildPgPaymentData 와 동일 기준).
|
||||
// base≠결제 통화에서 PG 통보 금액(환산 통화)과 단위가 일치하도록 한다.
|
||||
$expectedAmount = app(CurrencyConversionService::class)->resolveOrderPaymentChargeAmount($order);
|
||||
$expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'vbank_notify', [
|
||||
'source' => $source,
|
||||
'tid' => $notify['tid'],
|
||||
'received_amount' => $notify['amount'],
|
||||
]);
|
||||
if ($expectedAmount === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($expectedAmount > 0 && $notify['amount'] !== $expectedAmount) {
|
||||
Log::warning('KG Inicis: vbank notify rejected - amount mismatch', [
|
||||
'source' => $source,
|
||||
|
||||
+10
-1
@@ -65,7 +65,16 @@ class PaymentCloseReportController
|
||||
]);
|
||||
}
|
||||
|
||||
if ($price !== $this->expectedPaymentPrice($order)) {
|
||||
$expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'close_report', [
|
||||
'received_amount' => $price,
|
||||
]);
|
||||
if ($expectedPrice === null) {
|
||||
return ResponseHelper::error('messages.failed', 422, [
|
||||
'message' => ['Payment currency is not chargeable.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($price !== $expectedPrice) {
|
||||
return ResponseHelper::error('messages.failed', 422, [
|
||||
'message' => ['Payment amount does not match the order amount.'],
|
||||
]);
|
||||
|
||||
+11
-1
@@ -108,7 +108,17 @@ class PaymentSignatureController
|
||||
], 403);
|
||||
}
|
||||
|
||||
if ($price !== $this->expectedPaymentPrice($order)) {
|
||||
$expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'signature', [
|
||||
'received_amount' => $price,
|
||||
]);
|
||||
if ($expectedPrice === null) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Payment currency is not chargeable.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($price !== $expectedPrice) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Payment amount does not match the order amount.',
|
||||
|
||||
@@ -10,16 +10,19 @@ use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\PreventsReplayCallback;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\SanitizesPgResponse;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\ValidatesCbtOrderContext;
|
||||
use Plugins\Sirsoft\PayKginicis\Repositories\CbtCvsOperationsRepositoryInterface;
|
||||
|
||||
class CbtCvsOperationsService
|
||||
{
|
||||
use PreventsReplayCallback;
|
||||
use SanitizesPgResponse;
|
||||
use SerializesPaymentCallbacks;
|
||||
use ValidatesCbtOrderContext;
|
||||
|
||||
private const HISTORY_LIMIT = 20;
|
||||
|
||||
@@ -64,6 +67,7 @@ class CbtCvsOperationsService
|
||||
$amount = (int) ($payload['amount'] ?? 0);
|
||||
$payment = null;
|
||||
$existingMeta = [];
|
||||
$callbackLock = null;
|
||||
|
||||
if ($tid === '' || $orderId === '' || $mid === '' || $amount <= 0) {
|
||||
Log::warning('KG Inicis CBT CVS: invalid notify payload', [
|
||||
@@ -85,6 +89,8 @@ class CbtCvsOperationsService
|
||||
return $this->notifyResult('FAIL', 'failed', 'order_not_found');
|
||||
}
|
||||
|
||||
$callbackLock = $this->acquireOrderCallbackLock('cbtCvsNotify', $orderId);
|
||||
|
||||
$payment = $this->repository->firstPaymentForOrder($order);
|
||||
if (! $payment) {
|
||||
Log::warning('KG Inicis CBT CVS: payment row not found', [
|
||||
@@ -177,6 +183,16 @@ class CbtCvsOperationsService
|
||||
}
|
||||
|
||||
$expectedAmount = $this->resolveExpectedCvsAmount($order, $existingMeta);
|
||||
if ($expectedAmount === null) {
|
||||
$this->storeNotifyHistory($payment, $existingMeta, $payload, 'failed', 'invalid_payment_currency', $source, $remoteIp);
|
||||
Log::warning('KG Inicis CBT CVS: payment currency is not chargeable', [
|
||||
'order_id' => $orderId,
|
||||
'tid' => $tid,
|
||||
]);
|
||||
|
||||
return $this->notifyResult('FAIL', 'failed', 'invalid_payment_currency');
|
||||
}
|
||||
|
||||
if ($expectedAmount > 0 && $amount !== $expectedAmount) {
|
||||
$this->storeNotifyHistory($payment, $existingMeta, $payload, 'failed', 'amount_mismatch', $source, $remoteIp);
|
||||
Log::warning('KG Inicis CBT CVS: amount mismatch', [
|
||||
@@ -251,6 +267,8 @@ class CbtCvsOperationsService
|
||||
]);
|
||||
|
||||
return $this->notifyResult('FAIL', 'failed', 'exception');
|
||||
} finally {
|
||||
$this->releaseOrderCallbackLock($callbackLock);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,6 +356,11 @@ class CbtCvsOperationsService
|
||||
return $this->operationError('messages.cbt_cvs.not_waiting_deposit', 422);
|
||||
}
|
||||
|
||||
$expectedAmount = $this->resolveExpectedCvsAmount($order, $meta);
|
||||
if ($expectedAmount === null) {
|
||||
return $this->operationError('messages.cbt_cvs.simulate_failed', 422);
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$payload = [
|
||||
'tid' => $payment->transaction_id ?: 'ADMIN_CVS_'.$order->order_number,
|
||||
@@ -353,7 +376,7 @@ class CbtCvsOperationsService
|
||||
'confNo' => (string) ($meta['cvs_conf_no'] ?? ''),
|
||||
'receiptNo' => (string) ($meta['cvs_receipt_no'] ?? ''),
|
||||
'paymentTerm' => (string) ($meta['cvs_payment_term'] ?? ''),
|
||||
'amount' => (string) $this->resolveExpectedCvsAmount($order, $meta),
|
||||
'amount' => (string) $expectedAmount,
|
||||
'currencyCd' => 'JPY',
|
||||
];
|
||||
|
||||
@@ -599,7 +622,7 @@ class CbtCvsOperationsService
|
||||
return $paymentTermAt instanceof CarbonImmutable && $paymentTermAt->isPast();
|
||||
}
|
||||
|
||||
private function resolveExpectedCvsAmount(Order $order, array $paymentMeta): int
|
||||
private function resolveExpectedCvsAmount(Order $order, array $paymentMeta): ?int
|
||||
{
|
||||
$metaAmount = (int) ($paymentMeta['cvs_amount'] ?? 0);
|
||||
if ($metaAmount > 0) {
|
||||
@@ -608,7 +631,9 @@ class CbtCvsOperationsService
|
||||
|
||||
// 결제 청구액 SSoT = 결제 통화(order_currency) 환산액 (buildPgPaymentData 와 동일 기준).
|
||||
// base≠결제 통화에서 CVS 통보 금액(결제 통화)과 단위가 일치하도록 한다.
|
||||
return app(CurrencyConversionService::class)->resolveOrderPaymentChargeAmount($order);
|
||||
return $this->resolveExpectedPaymentPriceOrNull($order, 'cbt_cvs_notify', [
|
||||
'pay_method' => 'CVS',
|
||||
]);
|
||||
}
|
||||
|
||||
private function parseCbtDateTime(?string $value): ?CarbonImmutable
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Tests\Feature\Concerns;
|
||||
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Plugins\Sirsoft\PayKginicis\Concerns\SerializesPaymentCallbacks;
|
||||
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
|
||||
|
||||
class SerializesPaymentCallbacksTest extends PluginTestCase
|
||||
{
|
||||
private object $traitSubject;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->traitSubject = new class {
|
||||
use SerializesPaymentCallbacks {
|
||||
acquireOrderCallbackLock as public;
|
||||
releaseOrderCallbackLock as public;
|
||||
callbackLockKey as public;
|
||||
}
|
||||
|
||||
protected function callbackLockWaitSeconds(): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function callbackLockTtlSeconds(): int
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function test_same_context_and_order_are_serialized(): void
|
||||
{
|
||||
$orderNumber = 'ORD-KG-LOCK-' . random_int(10000, 99999);
|
||||
$firstLock = $this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber);
|
||||
|
||||
try {
|
||||
$this->expectException(LockTimeoutException::class);
|
||||
|
||||
$this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber);
|
||||
} finally {
|
||||
$this->traitSubject->releaseOrderCallbackLock($firstLock);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_released_lock_allows_next_callback(): void
|
||||
{
|
||||
$orderNumber = 'ORD-KG-LOCK-' . random_int(10000, 99999);
|
||||
$firstLock = $this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber);
|
||||
$this->traitSubject->releaseOrderCallbackLock($firstLock);
|
||||
|
||||
$secondLock = $this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber);
|
||||
|
||||
$this->assertNotNull($secondLock);
|
||||
$this->traitSubject->releaseOrderCallbackLock($secondLock);
|
||||
}
|
||||
|
||||
public function test_lock_key_is_scoped_by_context_and_order(): void
|
||||
{
|
||||
$this->assertNotSame(
|
||||
$this->traitSubject->callbackLockKey('authCallback', 'ORD-KG-LOCK-A'),
|
||||
$this->traitSubject->callbackLockKey('vbankNotify', 'ORD-KG-LOCK-A'),
|
||||
);
|
||||
|
||||
$this->assertNotSame(
|
||||
$this->traitSubject->callbackLockKey('authCallback', 'ORD-KG-LOCK-A'),
|
||||
$this->traitSubject->callbackLockKey('authCallback', 'ORD-KG-LOCK-B'),
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -69,7 +69,7 @@ class AdminCbtTestProductControllerTest extends PluginTestCase
|
||||
'order_number' => 'JP-ORDER-ADMIN-CBT-001',
|
||||
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
'currency' => 'JPY',
|
||||
'currency_snapshot' => ['JPY' => 1.0],
|
||||
'currency_snapshot' => self::jpyCurrencySnapshot(),
|
||||
'subtotal_amount' => 100,
|
||||
'total_amount' => 100,
|
||||
'total_due_amount' => 100,
|
||||
@@ -86,7 +86,7 @@ class AdminCbtTestProductControllerTest extends PluginTestCase
|
||||
'paid_amount_local' => 100,
|
||||
'paid_amount_base' => 100,
|
||||
'currency' => 'JPY',
|
||||
'currency_snapshot' => ['JPY' => 1.0],
|
||||
'currency_snapshot' => self::jpyCurrencySnapshot(),
|
||||
'payment_meta' => [
|
||||
'is_cbt' => true,
|
||||
'cbt_type' => 'JPPG',
|
||||
|
||||
+4
-2
@@ -1284,6 +1284,7 @@ class CbtPaymentControllerTest extends PluginTestCase
|
||||
$order->order_status = OrderStatusEnum::PENDING_ORDER;
|
||||
$order->currency = 'JPY';
|
||||
$order->total_due_amount = $amount;
|
||||
$order->currency_snapshot = self::jpyCurrencySnapshot();
|
||||
|
||||
return $order;
|
||||
}
|
||||
@@ -1294,7 +1295,7 @@ class CbtPaymentControllerTest extends PluginTestCase
|
||||
'order_number' => $orderNumber,
|
||||
'order_status' => OrderStatusEnum::PENDING_ORDER,
|
||||
'currency' => 'JPY',
|
||||
'currency_snapshot' => ['JPY' => 1.0],
|
||||
'currency_snapshot' => self::jpyCurrencySnapshot(),
|
||||
'subtotal_amount' => $amount,
|
||||
'total_discount_amount' => 0,
|
||||
'total_coupon_discount_amount' => 0,
|
||||
@@ -1328,7 +1329,7 @@ class CbtPaymentControllerTest extends PluginTestCase
|
||||
'paid_amount_local' => 0,
|
||||
'paid_amount_base' => 0,
|
||||
'currency' => 'JPY',
|
||||
'currency_snapshot' => ['JPY' => 1.0],
|
||||
'currency_snapshot' => self::jpyCurrencySnapshot(),
|
||||
'paid_at' => null,
|
||||
]);
|
||||
|
||||
@@ -1369,6 +1370,7 @@ class CbtPaymentControllerTest extends PluginTestCase
|
||||
'order_number' => $orderNumber,
|
||||
'order_status' => OrderStatusEnum::PENDING_ORDER->value,
|
||||
'currency' => 'JPY',
|
||||
'currency_snapshot' => json_encode(self::jpyCurrencySnapshot(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'subtotal_amount' => $amount,
|
||||
'total_amount' => $amount,
|
||||
'total_tax_amount' => $amount,
|
||||
|
||||
+2
@@ -48,6 +48,8 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
'user_id' => $user->id,
|
||||
'order_number' => 'ORD-TEST-' . random_int(10000, 99999),
|
||||
'order_status' => OrderStatusEnum::PENDING_ORDER,
|
||||
'currency' => 'KRW',
|
||||
'currency_snapshot' => self::krwCurrencySnapshot(),
|
||||
'subtotal_amount' => $totalAmount,
|
||||
'total_discount_amount' => 0,
|
||||
'total_coupon_discount_amount' => 0,
|
||||
|
||||
+26
@@ -77,6 +77,30 @@ class PaymentCloseReportControllerTest extends PluginTestCase
|
||||
->assertJsonPath('errors.message.0', 'Payment amount does not match the order amount.');
|
||||
}
|
||||
|
||||
public function test_close_report_rejects_unchargeable_payment_currency_without_server_error(): void
|
||||
{
|
||||
$order = $this->makeOrder('ORD-CLOSE-CURRENCY-001', 10000);
|
||||
$order->currency_snapshot = self::unchargeableKrwCurrencySnapshot();
|
||||
|
||||
$orderService = Mockery::mock(OrderProcessingService::class);
|
||||
$orderService->shouldReceive('findByOrderNumber')
|
||||
->once()
|
||||
->with('ORD-CLOSE-CURRENCY-001')
|
||||
->andReturn($order);
|
||||
$orderService->shouldNotReceive('failPayment');
|
||||
$orderService->shouldNotReceive('recordPaymentCancellation');
|
||||
|
||||
$this->app->instance(OrderProcessingService::class, $orderService);
|
||||
|
||||
$response = $this->postJson('/api/plugins/sirsoft-pay_kginicis/payment/close-report', [
|
||||
'oid' => 'ORD-CLOSE-CURRENCY-001',
|
||||
'price' => 10000,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonPath('errors.message.0', 'Payment currency is not chargeable.');
|
||||
}
|
||||
|
||||
public function test_close_report_rejects_non_krw_order_without_failing_payment(): void
|
||||
{
|
||||
$order = $this->makeOrder('ORD-CLOSE-USD-001', 10000, 'USD');
|
||||
@@ -199,6 +223,7 @@ class PaymentCloseReportControllerTest extends PluginTestCase
|
||||
'order_number' => 'ORD-CLOSE-EASYPAY-001',
|
||||
'order_status' => OrderStatusEnum::PENDING_ORDER,
|
||||
'currency' => 'KRW',
|
||||
'currency_snapshot' => self::krwCurrencySnapshot(),
|
||||
'subtotal_amount' => 10000,
|
||||
'total_amount' => 10000,
|
||||
'total_due_amount' => 10000,
|
||||
@@ -246,6 +271,7 @@ class PaymentCloseReportControllerTest extends PluginTestCase
|
||||
$order->order_status = OrderStatusEnum::PENDING_ORDER;
|
||||
$order->currency = $currency;
|
||||
$order->total_due_amount = $amount;
|
||||
$order->currency_snapshot = self::currencySnapshotFor($currency);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
+56
-1
@@ -79,6 +79,33 @@ class SignatureControllerTest extends PluginTestCase
|
||||
->assertJsonPath('message', 'Payment amount does not match the order amount.');
|
||||
}
|
||||
|
||||
public function test_pc_signature_rejects_unchargeable_payment_currency_without_server_error(): void
|
||||
{
|
||||
$order = $this->makePendingOrder('ORD-SIGN-CURRENCY-001', 10000, 'KRW', self::unchargeableKrwCurrencySnapshot());
|
||||
|
||||
$orderService = Mockery::mock(OrderProcessingService::class);
|
||||
$orderService->shouldReceive('findByOrderNumber')
|
||||
->with('ORD-SIGN-CURRENCY-001')
|
||||
->andReturn($order);
|
||||
|
||||
$apiService = Mockery::mock(KgInicisApiService::class);
|
||||
$apiService->shouldNotReceive('hasStandardPaymentCredentials');
|
||||
$apiService->shouldNotReceive('generateSignature');
|
||||
$apiService->shouldNotReceive('generateVerification');
|
||||
|
||||
$this->app->instance(OrderProcessingService::class, $orderService);
|
||||
$this->app->instance(KgInicisApiService::class, $apiService);
|
||||
|
||||
$response = $this->postJson('/api/plugins/sirsoft-pay_kginicis/payment/signature', [
|
||||
'oid' => 'ORD-SIGN-CURRENCY-001',
|
||||
'price' => 10000,
|
||||
'timestamp' => $this->freshEpochMs(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonPath('message', 'Payment currency is not chargeable.');
|
||||
}
|
||||
|
||||
public function test_pc_signature_rejects_non_krw_order(): void
|
||||
{
|
||||
$order = $this->makePendingOrder('ORD-SIGN-USD-001', 10000, 'USD');
|
||||
@@ -216,6 +243,32 @@ class SignatureControllerTest extends PluginTestCase
|
||||
->assertJsonPath('message', 'KG Inicis mobile payment credentials are not configured.');
|
||||
}
|
||||
|
||||
public function test_mobile_signature_rejects_unchargeable_payment_currency_without_server_error(): void
|
||||
{
|
||||
$order = $this->makePendingOrder('ORD-SIGN-CURRENCY-002', 10000, 'KRW', self::unchargeableKrwCurrencySnapshot());
|
||||
|
||||
$orderService = Mockery::mock(OrderProcessingService::class);
|
||||
$orderService->shouldReceive('findByOrderNumber')
|
||||
->with('ORD-SIGN-CURRENCY-002')
|
||||
->andReturn($order);
|
||||
|
||||
$apiService = Mockery::mock(KgInicisApiService::class);
|
||||
$apiService->shouldNotReceive('hasMobilePaymentCredentials');
|
||||
$apiService->shouldNotReceive('generateMobileChkfake');
|
||||
|
||||
$this->app->instance(OrderProcessingService::class, $orderService);
|
||||
$this->app->instance(KgInicisApiService::class, $apiService);
|
||||
|
||||
$response = $this->postJson('/api/plugins/sirsoft-pay_kginicis/payment/mobile/signature', [
|
||||
'oid' => 'ORD-SIGN-CURRENCY-002',
|
||||
'price' => 10000,
|
||||
'timestamp' => $this->freshEpochMs(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonPath('message', 'Payment currency is not chargeable.');
|
||||
}
|
||||
|
||||
public function test_pc_signature_expects_converted_price_for_non_base_order_currency(): void
|
||||
{
|
||||
// base=USD, 결제통화=KRW. base $6 → KRW 7058 환산이 검증 기준(price)이어야 한다.
|
||||
@@ -266,7 +319,9 @@ class SignatureControllerTest extends PluginTestCase
|
||||
$order->order_status = OrderStatusEnum::PENDING_ORDER;
|
||||
$order->currency = $currency;
|
||||
$order->total_due_amount = $amount;
|
||||
$order->currency_snapshot = $currencySnapshot;
|
||||
$order->currency_snapshot = $currencySnapshot !== []
|
||||
? $currencySnapshot
|
||||
: self::currencySnapshotFor($currency);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
+6
@@ -39,6 +39,8 @@ class VbankNotifyReplayTest extends PluginTestCase
|
||||
'user_id' => $user->id,
|
||||
'order_number' => 'ORD-VR-' . random_int(10000, 99999),
|
||||
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
'currency' => 'KRW',
|
||||
'currency_snapshot' => self::krwCurrencySnapshot(),
|
||||
'subtotal_amount' => $amount,
|
||||
'total_discount_amount' => 0,
|
||||
'total_coupon_discount_amount' => 0,
|
||||
@@ -139,6 +141,8 @@ class VbankNotifyReplayTest extends PluginTestCase
|
||||
'user_id' => $user->id,
|
||||
'order_number' => 'ORD-FA-' . random_int(10000, 99999),
|
||||
'order_status' => OrderStatusEnum::PENDING_ORDER,
|
||||
'currency' => 'KRW',
|
||||
'currency_snapshot' => self::krwCurrencySnapshot(),
|
||||
'subtotal_amount' => 30000,
|
||||
'total_discount_amount' => 0,
|
||||
'total_coupon_discount_amount' => 0,
|
||||
@@ -203,6 +207,8 @@ class VbankNotifyReplayTest extends PluginTestCase
|
||||
'user_id' => User::factory()->create()->id,
|
||||
'order_number' => 'ORD-DUP-' . random_int(10000, 99999),
|
||||
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
'currency' => 'KRW',
|
||||
'currency_snapshot' => self::krwCurrencySnapshot(),
|
||||
'subtotal_amount' => 20000,
|
||||
'total_discount_amount' => 0,
|
||||
'total_coupon_discount_amount' => 0,
|
||||
|
||||
@@ -62,6 +62,74 @@ abstract class PluginTestCase extends TestCase
|
||||
$this->registerEcommerceModuleInManager();
|
||||
}
|
||||
|
||||
protected 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,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected static function jpyCurrencySnapshot(): array
|
||||
{
|
||||
return [
|
||||
'base_currency' => 'JPY',
|
||||
'order_currency' => 'JPY',
|
||||
'base_unit' => 1,
|
||||
'exchange_rates' => [
|
||||
'JPY' => [
|
||||
'rate' => 1,
|
||||
'rounding_unit' => '1',
|
||||
'rounding_method' => 'round',
|
||||
'decimal_places' => 0,
|
||||
'base_unit' => 1,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected static function unchargeableKrwCurrencySnapshot(): array
|
||||
{
|
||||
return [
|
||||
'base_currency' => 'USD',
|
||||
'order_currency' => 'KRW',
|
||||
'base_unit' => 1,
|
||||
'exchange_rates' => [
|
||||
'USD' => [
|
||||
'rate' => 1,
|
||||
'rounding_unit' => '0.01',
|
||||
'rounding_method' => 'round',
|
||||
'decimal_places' => 2,
|
||||
'base_unit' => 1,
|
||||
],
|
||||
'KRW' => [
|
||||
'rate' => 0,
|
||||
'rounding_unit' => '1',
|
||||
'rounding_method' => 'round',
|
||||
'decimal_places' => 0,
|
||||
'base_unit' => 1,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected static function currencySnapshotFor(string $currency): array
|
||||
{
|
||||
return strtoupper($currency) === 'JPY'
|
||||
? self::jpyCurrencySnapshot()
|
||||
: self::krwCurrencySnapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 환경에서 sirsoft-ecommerce 모듈을 ModuleManager 에 명시 등록.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user