Files
Gnuboard7/plugins/_bundled/sirsoft-tosspayments/src/Controllers/PaymentCallbackController.php
T
HeuJung 26a349590e feat(plugins): 토스페이먼츠 번들 플러그인 복원
7.0.0 정식 출시 커밋에서 PG 3종이 함께 제거되었으나, 그중 토스페이먼츠만
번들로 되돌린다. 삭제 직전 트리를 기준으로 플러그인 40개 파일과 일본어
언어팩 6개 파일을 복원하고, 삭제에 연동되어 변경되었던 README 목록·언어팩
빌드 스크립트 엔트리·이커머스 편집기 샘플의 PG provider 를 함께 되살렸다.

복원된 코드는 삭제 시점에 멈춰 있어 현행 코드베이스와 어긋나는 지점이 있어
함께 정합화했다.

- PluginTestCase 가 활성 디렉토리 경로를 참조해 _bundled 단독 실행 시
 라우트가 등록되지 않던 문제를 자기 기준 경로로 교체
- SettingsServiceProvider 의 site_url override 로 app.url 이 오염되어
 assertRedirect 가 간헐 실패하던 문제를 APP_URL 명시 리셋으로 차단
- 환경설정 레이아웃의 시맨틱 클래스가 한 칸씩 밀려 순환 배치되어 2열 폼이
 무너지고 섹션 제목이 에러 색으로 표시되던 결함을 정정 (회귀 테스트 신설)
- 편집기 저장 시 silent drop 되던 미표준 root 키 제거, Icon 을 size prop 으로 전환
2026-08-06 11:29:47 +09:00

238 lines
8.1 KiB
PHP

<?php
namespace Plugins\Sirsoft\Tosspayments\Controllers;
use App\Extension\HookManager;
use App\Services\PluginSettingsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\Tosspayments\Http\Requests\FailCallbackRequest;
use Plugins\Sirsoft\Tosspayments\Http\Requests\SuccessCallbackRequest;
use Plugins\Sirsoft\Tosspayments\Services\TossPaymentsApiService;
/**
* 토스페이먼츠 결제 콜백 컨트롤러
*
* 토스페이먼츠 결제 완료/실패 후 브라우저 리다이렉트를 처리합니다.
* 성공 시 Confirm API를 호출하고, 주문 상태를 업데이트합니다.
*/
class PaymentCallbackController
{
private const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
/**
* 카드 발급사 코드 → 이름 매핑
*/
private const CARD_ISSUER_MAP = [
'3K' => '기업 BC',
'46' => '광주',
'71' => '롯데',
'30' => '산업',
'31' => '비씨',
'51' => '삼성',
'38' => '새마을금고',
'41' => '신한',
'62' => '신협',
'36' => '씨티',
'33' => '우리',
'W1' => '우리',
'37' => '우체국',
'39' => '저축',
'35' => '전북',
'42' => '제주',
'15' => '카카오뱅크',
'3A' => '케이뱅크',
'24' => '토스뱅크',
'21' => '하나',
'61' => '현대',
'11' => 'KB국민',
'91' => 'NH농협',
'34' => 'Sh수협',
];
public function __construct(
private OrderProcessingService $orderService,
private PluginSettingsService $pluginSettingsService,
private TossPaymentsApiService $apiService,
) {}
/**
* 토스페이먼츠 결제 성공 콜백
*
* GET /plugins/sirsoft-tosspayments/payment/success
* ?paymentKey={PK}&orderId={OID}&amount={AMT}
*
* @param SuccessCallbackRequest $request 검증된 콜백 요청
* @return RedirectResponse SPA 페이지로 리다이렉트
*/
public function success(SuccessCallbackRequest $request): RedirectResponse
{
$validated = $request->validated();
$paymentKey = $validated['paymentKey'];
$orderId = $validated['orderId'];
$pgAmount = $validated['amount'];
try {
// 1. 주문 조회 (주문번호 기반)
$order = $this->orderService->findByOrderNumber($orderId);
if (! $order) {
Log::error('TossPayments: order not found', ['orderId' => $orderId]);
return redirect($this->resolveFailUrl(['error' => 'order_not_found', 'orderId' => $orderId]));
}
// 2. TossPayments Confirm API 호출
HookManager::doAction('sirsoft-tosspayments.payment.before_confirm', $order, $paymentKey, $pgAmount);
$pgResponse = $this->apiService->confirmPayment($paymentKey, $orderId, $pgAmount);
HookManager::doAction('sirsoft-tosspayments.payment.after_confirm', $order, $pgResponse);
// 3. 주문 상태 업데이트 (금액 검증 + PG 응답 → order_payments 매핑)
$this->orderService->completePayment($order, [
'transaction_id' => $pgResponse['paymentKey'] ?? null,
'card_approval_number' => $pgResponse['card']['approveNo'] ?? null,
'card_number_masked' => $pgResponse['card']['number'] ?? null,
'card_name' => $this->resolveCardIssuer($pgResponse['card']['issuerCode'] ?? null),
'card_installment_months' => $pgResponse['card']['installmentPlanMonths'] ?? 0,
'is_interest_free' => $pgResponse['card']['isInterestFree'] ?? false,
'embedded_pg_provider' => $pgResponse['easyPay']['provider'] ?? null,
'receipt_url' => $pgResponse['receipt']['url'] ?? null,
'payment_meta' => [
'method' => $pgResponse['method'] ?? null,
'pg_raw_response' => $pgResponse,
],
'payment_device' => DeviceDetector::detect($request),
], $pgAmount);
// 4. SPA 주문 완료 페이지로 redirect
return redirect($this->resolveSuccessUrl($orderId));
} catch (PaymentAmountMismatchException $e) {
Log::error('TossPayments: amount mismatch', [
'orderId' => $orderId,
'expected' => $e->getExpectedAmount(),
'actual' => $e->getActualAmount(),
'context' => $e->getContext(),
]);
return redirect($this->resolveFailUrl([
'error' => 'amount_mismatch',
'orderId' => $orderId,
]));
} catch (\Exception $e) {
Log::error('TossPayments: confirm failed', [
'orderId' => $orderId,
'error' => $e->getMessage(),
'code' => $e->getCode(),
]);
return redirect($this->resolveFailUrl([
'error' => 'confirm_failed',
'message' => $e->getMessage(),
'orderId' => $orderId,
]));
}
}
/**
* 토스페이먼츠 결제 실패 콜백
*
* GET /plugins/sirsoft-tosspayments/payment/fail
* ?code={ERR}&message={MSG}&orderId={OID}
*
* @param FailCallbackRequest $request 검증된 콜백 요청
* @return RedirectResponse SPA 체크아웃 페이지로 리다이렉트
*/
public function fail(FailCallbackRequest $request): RedirectResponse
{
$validated = $request->validated();
$code = $validated['code'] ?? 'UNKNOWN';
$message = $validated['message'] ?? '';
$orderId = $validated['orderId'] ?? null;
Log::warning('TossPayments: payment failed', [
'code' => $code,
'message' => $message,
'orderId' => $orderId,
]);
if ($orderId) {
$order = $this->orderService->findByOrderNumber($orderId);
if ($order) {
$this->orderService->failPayment($order, $code, $message);
}
}
return redirect($this->resolveFailUrl([
'error' => $code,
'message' => $message,
'orderId' => $orderId,
]));
}
/**
* 카드 발급사 코드 → 이름 변환
*
* @param string|null $issuerCode 발급사 코드
* @return string|null 카드사명
*/
private function resolveCardIssuer(?string $issuerCode): ?string
{
if ($issuerCode === null) {
return null;
}
return self::CARD_ISSUER_MAP[$issuerCode] ?? $issuerCode;
}
/**
* 결제 성공 리다이렉트 URL 생성
*
* @param string $orderId 주문번호
* @return string 리다이렉트 URL
*/
private function resolveSuccessUrl(string $orderId): string
{
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
$urlTemplate = $settings['redirect_success_url'] ?? '/shop/orders/{orderId}/complete';
return str_replace('{orderId}', $orderId, $urlTemplate);
}
/**
* 결제 실패 리다이렉트 URL 생성
*
* @param array $queryParams 쿼리 파라미터
* @return string 리다이렉트 URL
*/
private function resolveFailUrl(array $queryParams = []): string
{
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
$baseUrl = $settings['redirect_fail_url'] ?? '/shop/checkout';
if (empty($queryParams)) {
return $baseUrl;
}
$query = http_build_query(array_filter($queryParams));
$separator = str_contains($baseUrl, '?') ? '&' : '?';
return $baseUrl . $separator . $query;
}
/**
* 결제 디바이스 판별 (User-Agent 기반)
*
* @param Request $request HTTP 요청
* @return string 디바이스 유형 (pc/mobile)
*/
}