fix(payment): 나이스페이 간편결제 표시 라벨 보강

나이스페이 간편결제 선택값을 콜백까지 보존하고 주문완료, 마이페이지, 관리자 주문목록/상세에서 실제 간편결제명을 표시한다.
This commit is contained in:
thisgun
2026-07-04 10:05:24 +09:00
parent b428734a06
commit 5a9ad97327
23 changed files with 891 additions and 27 deletions
@@ -17,6 +17,7 @@
### Fixed
- 나이스페이먼츠 간편결제 주문이 주문완료, 마이페이지 주문상세, 관리자 주문목록/상세에서 실제 간편결제 수단 대신 `신용카드`로만 표시되던 문제를 수정했습니다.
- 관리자 주문목록과 주문상세 레이아웃이 리뉴얼되거나 플러그인 업데이트 과정에서 나이스페이먼츠 테스트결제 배지, 테스트 모드 안내, 결제 조회/결제 정보 패널이 누락될 수 있던 문제를 수정했습니다.
- 운영 모드에서 live MID가 비어 있으면 `SR` 같은 잘못된 MID로 결제창을 만들지 않고 서명 요청 단계에서 실패하도록 보강했습니다.
- 환불 요청 시 현재 설정값이 아니라 결제 당시 MID와 테스트/운영 모드로 나이스페이먼츠 API 서명 컨텍스트를 복원하도록 수정했습니다.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -247,9 +247,13 @@ describe('requestPaymentHandler', () => {
const form = submitSpy.mock.instances[0] as HTMLFormElement;
const reservedInput = form.querySelector('input[name="NicepayReserved"]') as HTMLInputElement | null;
const mallReservedInput = form.querySelector('input[name="MallReserved"]') as HTMLInputElement | null;
const mallReserved1Input = form.querySelector('input[name="MallReserved1"]') as HTMLInputElement | null;
expect(getSubmittedPayMethod()).toBe('CARD');
expect(reservedInput?.value).toBe('DirectKakao=Y');
expect(mallReservedInput?.value).toBe('nicepay_easy_pay_method=nicepay_kakaopay');
expect(mallReserved1Input?.value).toBe('nicepay_kakaopay');
});
it('모바일 form 은 acceptCharset=euc-kr', async () => {
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { patchAdminPaymentMethodDisplay } from '../adminOrderPaymentDisplayInjector';
describe('NicePayments order payment display injectors', () => {
it('관리자 주문상세 결제수단 행을 간편결제 표시명으로 교체한다', () => {
document.body.innerHTML = `
<section id="section_payment_info">
<div>
<div>결제수단</div>
<div>신용카드</div>
<div>(일시불)</div>
</div>
</section>
`;
const root = document.getElementById('section_payment_info');
const patched = patchAdminPaymentMethodDisplay(root!, {
_pay_method_label: '카카오페이 (신용카드)',
_base_pay_method_label: '신용카드',
_embedded_pg_provider_label: '카카오페이',
});
expect(patched).toBe(true);
expect(root?.textContent).toContain('카카오페이 (신용카드, 일시불)');
expect(root?.textContent).not.toContain('(일시불)');
});
it('관리자 주문상세 결제수단 배지를 간편결제 라벨로 교체한다', () => {
document.body.innerHTML = `
<section id="section_payment_info">
<span class="rounded-full font-medium">신용카드</span>
</section>
`;
const root = document.getElementById('section_payment_info');
const patched = patchAdminPaymentMethodDisplay(root!, {
_pay_method_label: '네이버페이 (신용카드)',
_base_pay_method_label: '신용카드',
_embedded_pg_provider_label: '네이버페이',
});
expect(patched).toBe(true);
expect(root?.querySelector('span')?.textContent).toBe('네이버페이');
});
});
@@ -0,0 +1,163 @@
const PLUGIN_ID = 'sirsoft-pay_nicepayments';
const FLAG = '__nicepayAdminPaymentDisplayInjectorInstalled';
const ADMIN_ORDER_SHOW_RE = /^\/admin\/ecommerce\/orders\/([^/]+)$/;
interface AdminTransactionStatus {
_pay_method_label?: string | null;
_base_pay_method_label?: string | null;
_embedded_pg_provider_label?: string | null;
}
function getToken(): string | null {
return localStorage.getItem('auth_token');
}
async function fetchAdminTransactionStatus(orderNumber: string): Promise<AdminTransactionStatus | null> {
const token = getToken();
if (!token) return null;
try {
const res = await fetch(`/api/plugins/${PLUGIN_ID}/admin/orders/${orderNumber}/transaction-status`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!res.ok) return null;
const json = (await res.json()) as { data?: AdminTransactionStatus | null } | AdminTransactionStatus;
return ('data' in json ? json.data : json) ?? null;
} catch {
return null;
}
}
function directElementChildren(el: Element): HTMLElement[] {
return Array.from(el.children).filter(
(child): child is HTMLElement => child instanceof HTMLElement,
);
}
function patchLabeledPaymentMethod(root: Element, displayLabel: string): boolean {
let patched = false;
const rows = Array.from(root.querySelectorAll<HTMLElement>('div'));
for (const row of rows) {
const children = directElementChildren(row);
const labelIndex = children.findIndex(child => child.textContent?.trim() === '결제수단');
if (labelIndex < 0) continue;
const value = children
.slice(labelIndex + 1)
.find(child => child.textContent?.trim() && child.textContent.trim() !== '결제수단');
if (!value) continue;
const installment = children
.slice(labelIndex + 2)
.find(child => /^\(.+\)$/.test(child.textContent?.trim() ?? ''));
const installmentText = installment?.textContent?.trim().replace(/^\(|\)$/g, '') ?? '';
const nextDisplayLabel = installmentText && displayLabel.endsWith(')')
? displayLabel.replace(/\)$/, `, ${installmentText})`)
: displayLabel;
if (value.textContent?.trim() !== nextDisplayLabel) {
value.textContent = nextDisplayLabel;
value.dataset.nicepayPaymentMethodPatched = 'true';
}
row.dataset.nicepayPaymentMethodRow = 'admin-order';
if (installment && installmentText) {
installment.textContent = '';
installment.style.display = 'none';
installment.dataset.nicepayPaymentInstallmentHidden = 'true';
}
patched = true;
}
return patched;
}
function patchPaymentMethodBadge(root: Element, baseLabel: string, embeddedLabel: string): boolean {
const candidates = Array.from(root.querySelectorAll<HTMLElement>('span')).filter(
el => el.textContent?.trim() === baseLabel,
);
const badge = candidates.find(el => {
const className = typeof el.className === 'string' ? el.className : '';
return className.includes('rounded-full') || className.includes('font-medium');
});
if (!badge) return false;
badge.textContent = embeddedLabel;
badge.dataset.nicepayPaymentMethodPatched = 'true';
badge.dataset.nicepayPaymentMethodBadge = 'admin-order';
return true;
}
export function patchAdminPaymentMethodDisplay(
root: Element,
status: AdminTransactionStatus | null | undefined,
): boolean {
const displayLabel = status?._pay_method_label;
const embeddedLabel = status?._embedded_pg_provider_label;
const baseLabel = status?._base_pay_method_label;
if (!displayLabel || !embeddedLabel) return false;
const labeled = patchLabeledPaymentMethod(root, displayLabel);
const badge = baseLabel ? patchPaymentMethodBadge(root, baseLabel, embeddedLabel) : false;
return labeled || badge;
}
async function tryInject(orderNumber: string): Promise<boolean> {
const root = document.getElementById('section_payment_info') ?? document.getElementById('payment_info_card');
if (!root) return false;
const status = await fetchAdminTransactionStatus(orderNumber);
if (!status) return true;
const patched = patchAdminPaymentMethodDisplay(root, status);
if (patched) {
console.info(`[${PLUGIN_ID}] payment method display patched on admin order detail`);
}
return true;
}
function startPolling(orderNumber: string): void {
let attempts = 0;
const id = setInterval(() => {
attempts++;
void tryInject(orderNumber).then(done => {
if (done || attempts >= 30) clearInterval(id);
});
}, 400);
}
function onRouteChange(): void {
const match = location.pathname.match(ADMIN_ORDER_SHOW_RE);
if (match) startPolling(match[1]);
}
export function installAdminOrderPaymentDisplayInjector(): void {
if (typeof window === 'undefined') return;
const w = window as unknown as Record<string, unknown>;
if (w[FLAG]) return;
w[FLAG] = true;
console.info(`[${PLUGIN_ID}] admin order payment display injector installed`);
const schedule = (delay = 1500) => setTimeout(onRouteChange, delay);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => schedule());
} else {
schedule();
}
const origPush = history.pushState.bind(history);
history.pushState = (...args: Parameters<typeof history.pushState>) => {
origPush(...args);
schedule(600);
};
window.addEventListener('popstate', () => schedule(500));
}
@@ -267,6 +267,8 @@ export async function requestPaymentHandler(action: PaymentAction, _context?: un
DirectEasyPay: '',
NicepayReserved: '',
EasyPayMethod: '',
MallReserved: '',
MallReserved1: '',
};
// 휴대폰결제: 상품 유형 덮어쓰기 (0:디지털컨텐츠, 1:실물)
@@ -281,6 +283,8 @@ export async function requestPaymentHandler(action: PaymentAction, _context?: un
// 간편결제 directive 필드 설정 — gnu5 orderform.js switch(settle_method==='간편결제') 동일
if (isEasyPay) {
formFields.DirectShowOpt = 'CARD';
formFields.MallReserved = `nicepay_easy_pay_method=${encodeURIComponent(paymentMethod)}`;
formFields.MallReserved1 = paymentMethod;
switch (paymentMethod) {
case 'nicepay_naverpay':
formFields.DirectEasyPay = 'E020';
@@ -5,6 +5,7 @@ import { installCheckoutEasyPayInjector } from './checkoutEasyPayInjector';
import { installOrderCompleteReceiptInjector } from './orderCompleteReceiptInjector';
import { installMypageOrderShowInjector } from './mypageOrderShowInjector';
import { installAdminPaymentMethodBrandInjector } from './adminPaymentMethodBrandInjector';
import { installAdminOrderPaymentDisplayInjector } from './adminOrderPaymentDisplayInjector';
const PLUGIN_IDENTIFIER = 'sirsoft-pay_nicepayments';
@@ -53,6 +54,7 @@ function initPlugin(): void {
installOrderCompleteReceiptInjector();
installMypageOrderShowInjector();
installAdminPaymentMethodBrandInjector();
installAdminOrderPaymentDisplayInjector();
const doInit = () => {
const count = registerHandlers();
@@ -23,6 +23,11 @@ interface OrderData {
payment?: Payment;
}
interface ReceiptInfo {
receipt_url?: string | null;
payment_method_display_label?: string | null;
}
function getOrderFromState(orderNumber: string): OrderData | null {
try {
const g7 = (window as Record<string, unknown>).G7Core as Record<string, unknown> | undefined;
@@ -41,7 +46,7 @@ function getToken(): string | null {
return localStorage.getItem('auth_token');
}
async function fetchReceiptUrl(orderNumber: string): Promise<string | null> {
async function fetchReceiptInfo(orderNumber: string): Promise<ReceiptInfo | null> {
const token = getToken();
if (!token) return null;
try {
@@ -49,13 +54,37 @@ async function fetchReceiptUrl(orderNumber: string): Promise<string | null> {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!res.ok) return null;
const data = (await res.json()) as { receipt_url?: string };
return data.receipt_url ?? null;
return (await res.json()) as ReceiptInfo;
} catch {
return null;
}
}
function patchPaymentMethodDisplay(container: Element, displayLabel: string | null | undefined): boolean {
if (!displayLabel) return false;
const rows = Array.from(container.querySelectorAll<HTMLElement>('div'));
for (const row of rows) {
const spans = Array.from(row.children).filter(
(child): child is HTMLElement => child instanceof HTMLElement && child.tagName === 'SPAN',
);
if (spans.length < 2) continue;
const label = spans[0].textContent?.trim();
if (label !== '결제 방법' && label !== '결제수단' && label !== 'Payment Method') continue;
const value = spans[spans.length - 1];
if (value.textContent?.trim() !== displayLabel) {
value.textContent = displayLabel;
value.dataset.nicepayPaymentMethodPatched = 'true';
}
row.dataset.nicepayPaymentMethodRow = 'true';
return true;
}
return false;
}
function findPaymentContainer(): Element | null {
const panel = document.getElementById('order_payment_info_panel');
if (panel) {
@@ -91,9 +120,10 @@ function buildReceiptRow(orderNumber: string): HTMLElement {
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = '로딩 중...';
const url = await fetchReceiptUrl(orderNumber);
const data = await fetchReceiptInfo(orderNumber);
btn.disabled = false;
btn.textContent = '영수증 조회';
const url = data?.receipt_url ?? null;
if (url) {
window.open(url, 'nicepay_receipt', 'width=800,height=600,scrollbars=yes,resizable=yes');
}
@@ -188,6 +218,8 @@ async function tryInject(orderNumber: string): Promise<boolean> {
const container = findPaymentContainer();
if (!container) return false;
const receiptInfo = await fetchReceiptInfo(orderNumber);
patchPaymentMethodDisplay(container, receiptInfo?.payment_method_display_label);
// 가상계좌 입금 안내 — 결제수단이 vbank 이고 가상계좌 번호 발급된 경우 표시 (paid_at 무관: 입금 전후 모두 표시).
if (payment.payment_method === 'vbank' && payment.vbank_number && !document.getElementById(VBANK_BLOCK_ID)) {
@@ -11,6 +11,11 @@ type Payment = {
[key: string]: unknown;
};
interface ReceiptInfo {
receipt_url?: string | null;
payment_method_display_label?: string | null;
}
function getToken(): string | null {
return localStorage.getItem('auth_token');
}
@@ -31,7 +36,7 @@ async function fetchPayment(orderNumber: string): Promise<Payment | null> {
}
}
async function fetchReceiptUrl(orderNumber: string): Promise<string | null> {
async function fetchReceiptInfo(orderNumber: string): Promise<ReceiptInfo | null> {
const token = getToken();
if (!token) return null;
@@ -40,13 +45,37 @@ async function fetchReceiptUrl(orderNumber: string): Promise<string | null> {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!res.ok) return null;
const data = (await res.json()) as { receipt_url?: string };
return data.receipt_url ?? null;
return (await res.json()) as ReceiptInfo;
} catch {
return null;
}
}
function patchPaymentMethodDisplay(displayLabel: string | null | undefined): boolean {
if (!displayLabel) return false;
const rows = Array.from(document.querySelectorAll<HTMLElement>('div'));
for (const row of rows) {
const spans = Array.from(row.children).filter(
(child): child is HTMLElement => child instanceof HTMLElement && child.tagName === 'SPAN',
);
if (spans.length < 2) continue;
const label = spans[0].textContent?.trim();
if (label !== '결제 방법' && label !== '결제수단' && label !== '결제 방식') continue;
const value = spans[spans.length - 1];
if (value.textContent?.trim() !== displayLabel) {
value.textContent = displayLabel;
value.dataset.nicepayPaymentMethodPatched = 'true';
}
row.dataset.nicepayPaymentMethodRow = 'true';
return true;
}
return false;
}
async function injectOnOrderComplete(orderNumber: string): Promise<void> {
if (document.getElementById(BTN_ID)) return;
@@ -59,6 +88,9 @@ async function injectOnOrderComplete(orderNumber: string): Promise<void> {
if (!blueBtn?.parentElement) return;
const receiptInfo = await fetchReceiptInfo(orderNumber);
patchPaymentMethodDisplay(receiptInfo?.payment_method_display_label);
const container = blueBtn.parentElement;
const receiptBtn = document.createElement('button');
@@ -72,9 +104,10 @@ async function injectOnOrderComplete(orderNumber: string): Promise<void> {
receiptBtn.addEventListener('click', async () => {
receiptBtn.disabled = true;
receiptBtn.textContent = '로딩 중...';
const url = await fetchReceiptUrl(orderNumber);
const data = await fetchReceiptInfo(orderNumber);
receiptBtn.disabled = false;
receiptBtn.textContent = '영수증 조회';
const url = data?.receipt_url ?? null;
if (url) {
window.open(url, 'nicepay_receipt', 'width=800,height=600,scrollbars=yes,resizable=yes');
}
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNicepayments\Concerns;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
trait ResolvesEasyPayDisplay
{
private const EASY_PAY_METHODS = [
'nicepay_naverpay' => [
'provider' => 'naverpay',
'label' => ['ko' => '네이버페이', 'en' => 'NaverPay'],
],
'nicepay_kakaopay' => [
'provider' => 'kakaopay',
'label' => ['ko' => '카카오페이', 'en' => 'KakaoPay'],
],
'nicepay_samsungpay' => [
'provider' => 'samsungpay',
'label' => ['ko' => '삼성페이', 'en' => 'Samsung Pay'],
],
'nicepay_applepay' => [
'provider' => 'applepay',
'label' => ['ko' => '애플페이', 'en' => 'Apple Pay'],
],
'nicepay_payco' => [
'provider' => 'payco',
'label' => ['ko' => 'PAYCO (페이코)', 'en' => 'PAYCO'],
],
'nicepay_skpay' => [
'provider' => 'skpay',
'label' => ['ko' => 'SK pay', 'en' => 'SK pay'],
],
'nicepay_ssgpay' => [
'provider' => 'ssgpay',
'label' => ['ko' => 'SSG PAY', 'en' => 'SSG PAY'],
],
'nicepay_lpay' => [
'provider' => 'lpay',
'label' => ['ko' => 'L.pay', 'en' => 'L.pay'],
],
];
/**
* @return array{method: string, provider: string, label: array{ko: string, en: string}}|array{}
*/
protected function resolveEasyPayMeta(array $payload): array
{
$method = $this->resolveEasyPayMethod($payload);
if ($method === null || ! isset(self::EASY_PAY_METHODS[$method])) {
return [];
}
$definition = self::EASY_PAY_METHODS[$method];
return [
'method' => $method,
'provider' => $definition['provider'],
'label' => $definition['label'],
];
}
/**
* @return array<string, mixed>
*/
protected function decodePaymentMeta(mixed $paymentMeta): array
{
if (is_array($paymentMeta)) {
return $paymentMeta;
}
if (! is_string($paymentMeta) || trim($paymentMeta) === '') {
return [];
}
$decoded = json_decode($paymentMeta, true);
return is_array($decoded) ? $decoded : [];
}
/**
* @return array{
* payment_method_label: string|null,
* payment_method_display_label: string|null,
* selected_payment_method: string|null,
* embedded_pg_provider: string|null,
* embedded_pg_provider_label: string|null
* }
*/
protected function resolvePaymentDisplay(object $payment): array
{
$paymentMeta = $this->decodePaymentMeta($payment->payment_meta ?? null);
$selectedPaymentMethod = $this->stringOrNull($paymentMeta['nicepay_easy_pay_method'] ?? null);
$provider = $this->stringOrNull(
$payment->embedded_pg_provider
?? $paymentMeta['nicepay_easy_pay_provider']
?? $paymentMeta['embedded_pg_provider']
?? null
);
if ($provider === null && $selectedPaymentMethod !== null) {
$provider = $this->stringOrNull(self::EASY_PAY_METHODS[$selectedPaymentMethod]['provider'] ?? null);
}
$baseLabel = $this->paymentMethodLabel($this->stringOrNull($payment->payment_method ?? null));
$embeddedLabel = $this->embeddedPgProviderLabel($provider, $paymentMeta);
return [
'payment_method_label' => $baseLabel,
'payment_method_display_label' => $this->paymentMethodDisplayLabel($baseLabel, $embeddedLabel),
'selected_payment_method' => $selectedPaymentMethod,
'embedded_pg_provider' => $provider,
'embedded_pg_provider_label' => $embeddedLabel,
];
}
protected function paymentMethodLabel(?string $method): ?string
{
if ($method === null || $method === '') {
return null;
}
return PaymentMethodEnum::tryFrom($method)?->label() ?? $method;
}
protected function paymentMethodDisplayLabel(?string $baseLabel, ?string $embeddedLabel): ?string
{
if ($baseLabel === null || $baseLabel === '') {
return $embeddedLabel;
}
if ($embeddedLabel === null || $embeddedLabel === '') {
return $baseLabel;
}
return $embeddedLabel . ' (' . $baseLabel . ')';
}
/**
* @param array<string, mixed> $paymentMeta
*/
protected function embeddedPgProviderLabel(?string $provider, array $paymentMeta = []): ?string
{
$metaLabel = $this->localizedLabel($paymentMeta['nicepay_easy_pay_label'] ?? null)
?? $this->localizedLabel($paymentMeta['embedded_pg_provider_label'] ?? null);
if ($metaLabel !== null) {
return $metaLabel;
}
if ($provider === null || $provider === '') {
return null;
}
foreach (self::EASY_PAY_METHODS as $definition) {
if (($definition['provider'] ?? null) === $provider) {
return $this->localizedLabel($definition['label'] ?? null);
}
}
return null;
}
/**
* @param array<string, mixed> $payload
*/
private function resolveEasyPayMethod(array $payload): ?string
{
foreach (['nicepay_easy_pay_method', 'MallReserved1'] as $key) {
$method = $this->stringOrNull($payload[$key] ?? null);
if ($method !== null && isset(self::EASY_PAY_METHODS[$method])) {
return $method;
}
}
$mallReserved = $this->stringOrNull($payload['MallReserved'] ?? null);
if ($mallReserved === null) {
return null;
}
$decoded = json_decode($mallReserved, true);
if (is_array($decoded)) {
$method = $this->stringOrNull($decoded['nicepay_easy_pay_method'] ?? null);
if ($method !== null && isset(self::EASY_PAY_METHODS[$method])) {
return $method;
}
}
parse_str($mallReserved, $parsed);
$method = $this->stringOrNull($parsed['nicepay_easy_pay_method'] ?? null);
if ($method !== null && isset(self::EASY_PAY_METHODS[$method])) {
return $method;
}
return isset(self::EASY_PAY_METHODS[$mallReserved]) ? $mallReserved : null;
}
private function localizedLabel(mixed $label): ?string
{
if (is_string($label) && trim($label) !== '') {
return $label;
}
if (! is_array($label)) {
return null;
}
$locale = app()->getLocale();
return $this->stringOrNull($label[$locale] ?? null)
?? $this->stringOrNull($label['ko'] ?? null)
?? $this->stringOrNull($label['en'] ?? null);
}
private function stringOrNull(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$value = trim($value);
return $value === '' ? null : $value;
}
}
@@ -8,9 +8,12 @@ use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Plugins\Sirsoft\PayNicepayments\Concerns\ResolvesEasyPayDisplay;
class AdminOrderListController extends AdminBaseController
{
use ResolvesEasyPayDisplay;
public function __construct()
{
parent::__construct();
@@ -45,4 +48,44 @@ class AdminOrderListController extends AdminBaseController
return ResponseHelper::success('messages.success', $map);
}
/**
* 나이스페이먼츠 간편결제 표시 맵 반환
*
* 관리자 주문목록은 코어 결제 리소스의 기본 결제수단 라벨을 사용하므로,
* 플러그인 데이터소스로 실제 간편결제 표시명을 보강한다.
*
* @return JsonResponse 주문번호별 표시 맵
*/
public function easyPayDisplayMap(): JsonResponse
{
$rows = DB::table('ecommerce_orders as o')
->join('ecommerce_order_payments as p', 'p.order_id', '=', 'o.id')
->whereIn('p.pg_provider', ['nicepayments', 'nicepay'])
->where('p.created_at', '>=', now()->subMonths(6))
->select([
'o.order_number',
'p.payment_method',
'p.embedded_pg_provider',
'p.payment_meta',
])
->get();
$map = [];
foreach ($rows as $row) {
$display = $this->resolvePaymentDisplay($row);
if (($display['embedded_pg_provider_label'] ?? null) === null) {
continue;
}
$map[$row->order_number] = [
'payment_method_label' => $display['payment_method_label'],
'payment_method_display_label' => $display['payment_method_display_label'],
'embedded_pg_provider' => $display['embedded_pg_provider'],
'embedded_pg_provider_label' => $display['embedded_pg_provider_label'],
];
}
return ResponseHelper::success('messages.success', $map);
}
}
@@ -8,10 +8,13 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Plugins\Sirsoft\PayNicepayments\Concerns\ResolvesEasyPayDisplay;
use Plugins\Sirsoft\PayNicepayments\Services\NicePaymentsApiService;
class AdminTransactionController extends AdminBaseController
{
use ResolvesEasyPayDisplay;
public function __construct(
private NicePaymentsApiService $apiService
) {
@@ -72,10 +75,21 @@ class AdminTransactionController extends AdminBaseController
$localPayment = DB::table('ecommerce_order_payments')
->where('transaction_id', $tid)
->select(['is_escrow', 'payment_meta'])
->select(['is_escrow', 'payment_meta', 'payment_method', 'embedded_pg_provider'])
->first();
$result['_local_is_escrow'] = (bool) ($localPayment?->is_escrow ?? false);
$display = $localPayment ? $this->resolvePaymentDisplay($localPayment) : [
'payment_method_label' => null,
'payment_method_display_label' => null,
'embedded_pg_provider' => null,
'embedded_pg_provider_label' => null,
];
$result['_base_pay_method_label'] = $display['payment_method_label'];
$result['_embedded_pg_provider'] = $display['embedded_pg_provider'];
$result['_embedded_pg_provider_label'] = $display['embedded_pg_provider_label'];
$result['_pay_method_label'] = $display['payment_method_display_label'];
$result['payment_method_display_label'] = $display['payment_method_display_label'];
if ($localPayment?->payment_meta) {
$meta = json_decode($localPayment->payment_meta, true);
@@ -23,6 +23,7 @@ use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\PayNicepayments\Concerns\PreventsReplayCallback;
use Plugins\Sirsoft\PayNicepayments\Concerns\RecordsPaymentWindowClosure;
use Plugins\Sirsoft\PayNicepayments\Concerns\ResolvesEasyPayDisplay;
use Plugins\Sirsoft\PayNicepayments\Concerns\SanitizesPgResponse;
use Plugins\Sirsoft\PayNicepayments\Http\Requests\AuthCallbackRequest;
use Plugins\Sirsoft\PayNicepayments\Http\Requests\VbankNotifyRequest;
@@ -40,6 +41,7 @@ class PaymentCallbackController
{
use PreventsReplayCallback;
use RecordsPaymentWindowClosure;
use ResolvesEasyPayDisplay;
use SanitizesPgResponse;
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nicepayments';
@@ -72,6 +74,7 @@ class PaymentCallbackController
'RcptTID',
'EscrowYN',
'MallReserved',
'MallReserved1',
'Currency',
];
@@ -92,6 +95,7 @@ class PaymentCallbackController
'RcptTID',
'EscrowYN',
'MallReserved',
'MallReserved1',
];
private const VBANK_NOTIFY_RESPONSE_KEYS = [
@@ -306,7 +310,7 @@ class PaymentCallbackController
]);
} else {
$effectiveTid = (string) ($pgResponse['TID'] ?? $txTid);
$completionStatus = $this->completeAuthorizedPaymentWithLock($moid, $pgResponse, $txTid, $amt, $isEscrow, $request);
$completionStatus = $this->completeAuthorizedPaymentWithLock($moid, $pgResponse, $txTid, $amt, $isEscrow, $request, $validated);
if ($completionStatus === 'already_paid') {
$this->logReplayDetected($effectiveTid, $moid, 'authCallback (card)');
@@ -468,8 +472,9 @@ class PaymentCallbackController
int $amt,
bool $isEscrow,
Request $request,
array $callbackPayload,
): string {
return DB::transaction(function () use ($moid, $pgResponse, $txTid, $amt, $isEscrow, $request): string {
return DB::transaction(function () use ($moid, $pgResponse, $txTid, $amt, $isEscrow, $request, $callbackPayload): string {
$lockedOrder = Order::query()
->where('order_number', $moid)
->lockForUpdate()
@@ -499,6 +504,7 @@ class PaymentCallbackController
}
$lockedOrder->setRelation('payment', $payment);
$easyPayMeta = $this->resolveEasyPayMeta(array_merge($pgResponse, $callbackPayload));
$this->orderService->completePayment($lockedOrder, [
'transaction_id' => $effectiveTid,
@@ -507,10 +513,17 @@ class PaymentCallbackController
'card_name' => $pgResponse['IssuCardName'] ?? $pgResponse['CardName'] ?? null,
'card_installment_months' => (int) ($pgResponse['CardQuota'] ?? 0),
'is_interest_free' => false,
'embedded_pg_provider' => null,
'embedded_pg_provider' => $easyPayMeta['provider'] ?? null,
'receipt_url' => 'https://npg.nicepay.co.kr/issue/IssueLoader.do?type=0&TID='.rawurlencode($effectiveTid),
'payment_meta' => [
'result_code' => $pgResponse['ResultCode'] ?? null,
...($easyPayMeta === [] ? [] : [
'nicepay_easy_pay_method' => $easyPayMeta['method'],
'nicepay_easy_pay_provider' => $easyPayMeta['provider'],
'nicepay_easy_pay_label' => $easyPayMeta['label'],
'embedded_pg_provider' => $easyPayMeta['provider'],
'embedded_pg_provider_label' => $easyPayMeta['label'],
]),
'pay_method' => $pgResponse['PayMethod'] ?? null,
'auth_date' => $pgResponse['AuthDate'] ?? null,
'mid' => $this->apiService->getMid(),
@@ -7,9 +7,12 @@ namespace Plugins\Sirsoft\PayNicepayments\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Plugins\Sirsoft\PayNicepayments\Concerns\ResolvesEasyPayDisplay;
class UserReceiptController
{
use ResolvesEasyPayDisplay;
private const RECEIPT_BASE_URL = 'https://npg.nicepay.co.kr/issue/IssueLoader.do';
/**
@@ -31,7 +34,13 @@ class UserReceiptController
->join('ecommerce_orders as o', 'o.id', '=', 'p.order_id')
->where('o.order_number', $orderNumber)
->where('o.user_id', $user->id)
->select(['p.transaction_id', 'p.receipt_url', 'p.payment_meta'])
->select([
'p.transaction_id',
'p.receipt_url',
'p.payment_meta',
'p.payment_method',
'p.embedded_pg_provider',
])
->first();
if (! $payment) {
@@ -45,6 +54,7 @@ class UserReceiptController
$cashReceiptUrl = null;
$isTestMode = false;
$display = $this->resolvePaymentDisplay($payment);
if ($payment->payment_meta) {
$meta = json_decode($payment->payment_meta, true);
$rcptTid = $meta['rcpt_tid'] ?? ($meta['pg_raw_response']['RcptTID'] ?? null);
@@ -58,6 +68,11 @@ class UserReceiptController
'receipt_url' => $receiptUrl,
'cash_receipt_url' => $cashReceiptUrl,
'is_test_mode' => $isTestMode,
'payment_method_label' => $display['payment_method_label'],
'payment_method_display_label' => $display['payment_method_display_label'],
'selected_payment_method' => $display['selected_payment_method'],
'embedded_pg_provider' => $display['embedded_pg_provider'],
'embedded_pg_provider_label' => $display['embedded_pg_provider_label'],
]);
}
}
@@ -53,6 +53,8 @@ class AuthCallbackRequest extends FormRequest
'AuthResultMsg' => ['nullable', 'string'],
'Moid' => ['nullable', 'string'],
'Amt' => ['nullable', 'integer', 'min:1'],
'MallReserved' => ['nullable', 'string', 'max:1000'],
'MallReserved1' => ['nullable', 'string', 'max:20'],
];
}
@@ -68,6 +70,8 @@ class AuthCallbackRequest extends FormRequest
'Amt' => ['required', 'integer', 'min:1'],
'NetCancelURL' => ['required', 'string', 'url'],
'Signature' => ['required', 'string'],
'MallReserved' => ['nullable', 'string', 'max:1000'],
'MallReserved1' => ['nullable', 'string', 'max:20'],
];
}
@@ -14,8 +14,12 @@ class EnsureAdminOrderListTestBadgeLayoutListener implements HookListenerInterfa
private const DATA_SOURCE_ID = 'nicepay_test_map';
private const EASY_PAY_DISPLAY_DATA_SOURCE_ID = 'nicepay_easy_pay_display_map';
private const BADGE_CONDITION = 'nicepay_test_map.data?.[row.order_number] === true';
private const EASY_PAY_DISPLAY_EXPRESSION = 'nicepay_easy_pay_display_map.data?.[row.order_number]?.payment_method_display_label ?? row.payment?.payment_method_label';
/**
* @return array<string, array<string, mixed>>
*/
@@ -65,24 +69,42 @@ class EnsureAdminOrderListTestBadgeLayoutListener implements HookListenerInterfa
? $layout['data_sources']
: [];
$existingIds = [];
foreach ($dataSources as $dataSource) {
if (is_array($dataSource) && ($dataSource['id'] ?? null) === self::DATA_SOURCE_ID) {
$layout['data_sources'] = $dataSources;
$existingIds[self::DATA_SOURCE_ID] = true;
}
return $layout;
if (is_array($dataSource) && ($dataSource['id'] ?? null) === self::EASY_PAY_DISPLAY_DATA_SOURCE_ID) {
$existingIds[self::EASY_PAY_DISPLAY_DATA_SOURCE_ID] = true;
}
}
$dataSources[] = [
'id' => self::DATA_SOURCE_ID,
'label_key' => '$t:sirsoft-pay_nicepayments.editor.data_source.nicepay_test_map',
'type' => 'api',
'endpoint' => '/api/plugins/sirsoft-pay_nicepayments/admin/orders/test-mode-map',
'method' => 'GET',
'auto_fetch' => true,
'auth_required' => true,
'loading_strategy' => 'progressive',
];
if (! isset($existingIds[self::DATA_SOURCE_ID])) {
$dataSources[] = [
'id' => self::DATA_SOURCE_ID,
'label_key' => '$t:sirsoft-pay_nicepayments.editor.data_source.nicepay_test_map',
'type' => 'api',
'endpoint' => '/api/plugins/sirsoft-pay_nicepayments/admin/orders/test-mode-map',
'method' => 'GET',
'auto_fetch' => true,
'auth_required' => true,
'loading_strategy' => 'progressive',
];
}
if (! isset($existingIds[self::EASY_PAY_DISPLAY_DATA_SOURCE_ID])) {
$dataSources[] = [
'id' => self::EASY_PAY_DISPLAY_DATA_SOURCE_ID,
'label_key' => '$t:sirsoft-pay_nicepayments.editor.data_source.nicepay_easy_pay_display_map',
'type' => 'api',
'endpoint' => '/api/plugins/sirsoft-pay_nicepayments/admin/orders/easy-pay-display-map',
'method' => 'GET',
'auto_fetch' => true,
'auth_required' => true,
'loading_strategy' => 'progressive',
];
}
$layout['data_sources'] = $dataSources;
@@ -131,12 +153,34 @@ class EnsureAdminOrderListTestBadgeLayoutListener implements HookListenerInterfa
}
$this->appendBadgeToColumn($column);
$this->ensurePaymentMethodDisplayExpression($column);
return;
}
unset($column);
}
/**
* @param array<string|int, mixed> $node
*/
private function ensurePaymentMethodDisplayExpression(array &$node): bool
{
if (($node['text'] ?? null) === '{{row.payment?.payment_method_label}}') {
$node['text'] = '{{'.self::EASY_PAY_DISPLAY_EXPRESSION.'}}';
return true;
}
foreach ($node as &$value) {
if (is_array($value) && $this->ensurePaymentMethodDisplayExpression($value)) {
return true;
}
}
unset($value);
return false;
}
/**
* @param array<string, mixed> $column
*/
@@ -55,6 +55,11 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g
->middleware('permission:admin,sirsoft-ecommerce.orders.read')
->name('orders.test-mode-map');
// 주문 목록 간편결제 표시 맵 조회
Route::get('/orders/easy-pay-display-map', [AdminOrderListController::class, 'easyPayDisplayMap'])
->middleware('permission:admin,sirsoft-ecommerce.orders.read')
->name('orders.easy-pay-display-map');
// 가상계좌 입금 완료 건 환불 (환불 계좌 정보 필요)
Route::post('/vbank-refund', [AdminVbankRefundController::class, 'refund'])
->middleware('permission:admin,sirsoft-ecommerce.orders.update')
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNicepayments\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 Plugins\Sirsoft\PayNicepayments\Services\NicePaymentsApiService;
use Plugins\Sirsoft\PayNicepayments\Tests\PluginTestCase;
class EasyPayDisplayControllerTest extends PluginTestCase
{
public function test_admin_order_list_easy_pay_display_map_returns_display_labels(): void
{
app()->setLocale('ko');
$admin = $this->createAdminUser(['sirsoft-ecommerce.orders.read']);
$order = $this->createPaidEasyPayOrder('nicepay_payco', 'payco', [
'ko' => 'PAYCO (페이코)',
'en' => 'PAYCO',
]);
$response = $this->actingAs($admin)
->getJson('/api/plugins/sirsoft-pay_nicepayments/admin/orders/easy-pay-display-map');
$response->assertOk();
$display = $response->json("data.{$order->order_number}");
$this->assertSame('payco', $display['embedded_pg_provider'] ?? null);
$this->assertContains($display['embedded_pg_provider_label'] ?? null, ['PAYCO (페이코)', 'PAYCO']);
$this->assertSame(
($display['embedded_pg_provider_label'] ?? '') . ' (' . ($display['payment_method_label'] ?? '') . ')',
$display['payment_method_display_label'] ?? null,
);
}
public function test_admin_transaction_status_includes_easy_pay_display_label(): void
{
app()->setLocale('ko');
$admin = $this->createAdminUser(['sirsoft-ecommerce.orders.read']);
$order = $this->createPaidEasyPayOrder('nicepay_kakaopay', 'kakaopay', [
'ko' => '카카오페이',
'en' => 'KakaoPay',
]);
$apiServiceMock = $this->createMock(NicePaymentsApiService::class);
$apiServiceMock->expects($this->once())
->method('queryTransaction')
->with('NICE_TID_' . $order->id)
->willReturn([
'ResultCode' => '3001',
'ResultMsg' => '정상처리',
'TID' => 'NICE_TID_' . $order->id,
'PayMethod' => 'CARD',
]);
$this->app->instance(NicePaymentsApiService::class, $apiServiceMock);
$response = $this->actingAs($admin)
->getJson("/api/plugins/sirsoft-pay_nicepayments/admin/orders/{$order->order_number}/transaction-status");
$response->assertOk()
->assertJsonPath('data._embedded_pg_provider', 'kakaopay');
$data = $response->json('data');
$this->assertContains($data['_embedded_pg_provider_label'] ?? null, ['카카오페이', 'KakaoPay']);
$this->assertSame(
($data['_embedded_pg_provider_label'] ?? '') . ' (' . ($data['_base_pay_method_label'] ?? '') . ')',
$data['_pay_method_label'] ?? null,
);
}
public function test_user_receipt_response_includes_easy_pay_display_label(): void
{
app()->setLocale('ko');
$user = User::factory()->create();
$order = $this->createPaidEasyPayOrder('nicepay_naverpay', 'naverpay', [
'ko' => '네이버페이',
'en' => 'NaverPay',
], $user);
$response = $this->actingAs($user)
->getJson("/api/plugins/sirsoft-pay_nicepayments/user/orders/{$order->order_number}/receipt");
$response->assertOk()
->assertJsonPath('selected_payment_method', 'nicepay_naverpay')
->assertJsonPath('embedded_pg_provider', 'naverpay');
$data = $response->json();
$this->assertContains($data['embedded_pg_provider_label'] ?? null, ['네이버페이', 'NaverPay']);
$this->assertSame(
($data['embedded_pg_provider_label'] ?? '') . ' (' . ($data['payment_method_label'] ?? '') . ')',
$data['payment_method_display_label'] ?? null,
);
}
/**
* @param array{ko: string, en: string} $label
*/
private function createPaidEasyPayOrder(
string $method,
string $provider,
array $label,
?User $user = null,
): \Modules\Sirsoft\Ecommerce\Models\Order {
$order = OrderFactory::new()->create([
'user_id' => ($user ?? User::factory()->create())->id,
'order_number' => 'ORD-NICE-EASY-' . random_int(10000, 99999),
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'subtotal_amount' => 50000,
'total_amount' => 50000,
'total_due_amount' => 0,
'total_paid_amount' => 50000,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
'paid_at' => now(),
]);
OrderPaymentFactory::new()->create([
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::PAID,
'payment_method' => PaymentMethodEnum::CARD,
'pg_provider' => 'nicepayments',
'transaction_id' => 'NICE_TID_' . $order->id,
'embedded_pg_provider' => $provider,
'paid_amount_local' => 50000,
'paid_at' => now(),
'payment_meta' => [
'nicepay_easy_pay_method' => $method,
'nicepay_easy_pay_provider' => $provider,
'nicepay_easy_pay_label' => $label,
'pg_raw_response' => [
'ResultCode' => '3001',
'PayMethod' => 'CARD',
],
],
]);
return $order;
}
}
@@ -63,6 +63,8 @@ class PaymentCallbackControllerTest extends PluginTestCase
'total_points_used_amount' => 0,
'total_deposit_used_amount' => 0,
'total_paid_amount' => 0,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
]);
OrderPaymentFactory::new()->create([
@@ -387,6 +389,42 @@ class PaymentCallbackControllerTest extends PluginTestCase
$this->assertEquals('APP12345', $payment->card_approval_number);
}
public function test_auth_callback_stores_easy_pay_display_metadata(): void
{
app()->setLocale('ko');
$order = $this->createTestOrder(50000);
$this->mockPluginSettings();
$tid = 'TID_EASY_PAY_' . uniqid();
$params = $this->makeCallbackParams($order->order_number, 50000, [
'MallReserved' => 'nicepay_easy_pay_method=nicepay_kakaopay',
'MallReserved1' => 'nicepay_kakaopay',
]);
Http::fake([
'pay.nicepay.co.kr/v1/authorize' => Http::response(
$this->makeAuthorizeResponse($tid, $order->order_number, 50000),
200
),
]);
$response = $this->post('/plugins/sirsoft-pay_nicepayments/payment/callback', $params);
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
$payment = $order->payment;
$payment->refresh();
$meta = $payment->payment_meta;
$this->assertSame('kakaopay', $payment->embedded_pg_provider);
$this->assertSame('nicepay_kakaopay', $meta['nicepay_easy_pay_method'] ?? null);
$this->assertSame('kakaopay', $meta['nicepay_easy_pay_provider'] ?? null);
$this->assertSame('카카오페이', $meta['nicepay_easy_pay_label']['ko'] ?? null);
$this->assertSame('KakaoPay', $meta['nicepay_easy_pay_label']['en'] ?? null);
$this->assertSame('kakaopay', $meta['embedded_pg_provider'] ?? null);
}
public function test_auth_callback_stores_only_whitelisted_pg_response_fields(): void
{
$order = $this->createTestOrder(50000);
@@ -21,6 +21,7 @@ class PaymentCloseReportControllerTest extends PluginTestCase
'order_number' => 'ORD-NICE-CLOSE-001',
'order_status' => OrderStatusEnum::PENDING_ORDER,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
'subtotal_amount' => 10000,
'total_amount' => 10000,
'total_due_amount' => 10000,
@@ -55,6 +56,7 @@ class PaymentCloseReportControllerTest extends PluginTestCase
'order_number' => 'ORD-NICE-CLOSE-REAL',
'order_status' => OrderStatusEnum::PENDING_ORDER,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
'subtotal_amount' => 10000,
'total_amount' => 10000,
'total_due_amount' => 10000,
@@ -172,6 +174,7 @@ class PaymentCloseReportControllerTest extends PluginTestCase
'order_number' => 'ORD-NICE-CLOSE-RACE',
'order_status' => OrderStatusEnum::PENDING_ORDER,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
'subtotal_amount' => 10000,
'total_amount' => 10000,
'total_due_amount' => 10000,
@@ -232,6 +235,7 @@ class PaymentCloseReportControllerTest extends PluginTestCase
'order_number' => 'ORD-NICE-CLOSE-PAID',
'order_status' => OrderStatusEnum::PENDING_ORDER,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
'subtotal_amount' => 10000,
'total_amount' => 10000,
'total_due_amount' => 10000,
@@ -270,6 +274,7 @@ class PaymentCloseReportControllerTest extends PluginTestCase
$order->order_number = $orderNumber;
$order->order_status = OrderStatusEnum::PENDING_ORDER;
$order->currency = 'KRW';
$order->currency_snapshot = self::krwCurrencySnapshot();
$order->total_due_amount = $amount;
return $order;
@@ -51,6 +51,24 @@ abstract class PluginTestCase extends TestCase
\Illuminate\Support\Facades\Config::set('app.url', env('APP_URL', 'http://localhost'));
}
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 function registerModuleAutoload(): void
{
$moduleBasePath = base_path('modules/sirsoft-ecommerce/src/');
@@ -18,9 +18,12 @@ class EnsureAdminOrderLayoutExtensionsListenerTest extends TestCase
$json = json_encode($result, JSON_UNESCAPED_SLASHES);
$this->assertSame(1, $this->countDataSources($result, 'nicepay_test_map'));
$this->assertSame(1, $this->countDataSources($result, 'nicepay_easy_pay_display_map'));
$this->assertSame(1, $this->countString($result, 'nicepay_test_map.data?.[row.order_number] === true'));
$this->assertSame(1, $this->countString($result, 'nicepay_easy_pay_display_map.data?.[row.order_number]?.payment_method_display_label ?? row.payment?.payment_method_label'));
$this->assertIsString($json);
$this->assertStringContainsString('/api/plugins/sirsoft-pay_nicepayments/admin/orders/test-mode-map', $json);
$this->assertStringContainsString('/api/plugins/sirsoft-pay_nicepayments/admin/orders/easy-pay-display-map', $json);
$this->assertStringContainsString('sirsoft-pay_nicepayments.admin.test_mode_badge', $json);
}
@@ -32,7 +35,9 @@ class EnsureAdminOrderLayoutExtensionsListenerTest extends TestCase
$twice = $listener->ensureTestBadgeLayout($once, 1);
$this->assertSame(1, $this->countDataSources($twice, 'nicepay_test_map'));
$this->assertSame(1, $this->countDataSources($twice, 'nicepay_easy_pay_display_map'));
$this->assertSame(1, $this->countString($twice, 'nicepay_test_map.data?.[row.order_number] === true'));
$this->assertSame(1, $this->countString($twice, 'nicepay_easy_pay_display_map.data?.[row.order_number]?.payment_method_display_label ?? row.payment?.payment_method_label'));
}
public function test_detail_listener_ensures_test_notice_and_payment_panels(): void