현금영수증 발급 프로바이더를 PG 와 독립적으로 선택할 수 있도록 훅 축을 신설한다. KG이니시스로 결제받으면서 현금영수증만 토스페이먼츠로 발급하는 구성이 가능하다. 발급/취소 원장(ecommerce_order_cash_receipts)이 국세청 신고 근거를 보관한다. 금액이 바뀌면 syncFromOrder 가 주문의 현재 상태에서 발급액을 재계산해 전체취소 → 전액 재발급한다. 부분취소 API 를 쓰지 않으므로 전액취소만 지원하는 벤더도 동일 인터페이스로 수용되고, 증가/감소 방향을 구분하지 않아 향후 반품·교환 배송비 청구가 도입되어도 수정 없이 동작한다. 배송비는 그동안 과세/면세 분류에서 아예 빠져 있었다. 배송비는 단계 3 에서 계산되고 할인은 단계 4 에서 적용되므로 상품 분류 시점(단계 2-b)에는 존재하지 않는다. 따라서 할인 후 배송비가 확정된 Summary 집계 직전에 3가지 정책 중 하나로 분류해 합산하고, 옵션별 과세액에는 배송비를 섞지 않는다. 재발급용 식별번호는 APP_KEY 기반으로 암호화 보관하고 구매확정 시 폐기한다. 이력에는 마스킹 값만 남기며 프로바이더 원응답의 민감 키도 가린다. 주민등록번호는 수집하지 않는다. cash_receipt_type 은 Enum 캐스트하지 않는다 — 레거시 값(income_deduction)이 남아 있는 동안 Laravel 의 ::from 이 ValueError 를 던져 정규화해야 할 업그레이드 스텝 자신이 그 행을 읽지 못하게 되기 때문이다.
1161 lines
45 KiB
PHP
1161 lines
45 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Ecommerce\Services;
|
|
|
|
use App\Contracts\Extension\ModuleSettingsInterface;
|
|
use App\Extension\HookManager;
|
|
use App\Traits\NormalizesSettingsData;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\File;
|
|
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
|
use Modules\Sirsoft\Ecommerce\Enums\RefundMethodEnum;
|
|
use Modules\Sirsoft\Ecommerce\Enums\ShippingFeeTaxPolicy;
|
|
|
|
/**
|
|
* 이커머스 모듈 환경설정 서비스
|
|
*
|
|
* ModuleSettingsInterface를 구현하여 모듈별 설정을 관리합니다.
|
|
*
|
|
* 다국어 카탈로그(결제수단/배송 가능 국가/통화) 라벨은 카탈로그 빌드 시점에 활성 언어팩으로
|
|
* 자동 보강 — 단순 패턴: 빌드 메서드 안에서 `localize_catalog_field()` helper 직접 호출.
|
|
*/
|
|
class EcommerceSettingsService implements ModuleSettingsInterface
|
|
{
|
|
use NormalizesSettingsData;
|
|
|
|
/**
|
|
* 모듈 식별자
|
|
*/
|
|
private const MODULE_IDENTIFIER = 'sirsoft-ecommerce';
|
|
|
|
/**
|
|
* 설정 기본값 (캐시)
|
|
*/
|
|
private ?array $defaults = null;
|
|
|
|
/**
|
|
* 현재 설정값 (캐시)
|
|
*/
|
|
private ?array $settings = null;
|
|
|
|
/**
|
|
* 모듈 설정 기본값 파일 경로 반환
|
|
*
|
|
* @return string|null defaults.json 파일의 절대 경로, 없으면 null
|
|
*/
|
|
public function getSettingsDefaultsPath(): ?string
|
|
{
|
|
$path = $this->getModulePath().'/config/settings/defaults.json';
|
|
|
|
return file_exists($path) ? $path : null;
|
|
}
|
|
|
|
/**
|
|
* 설정값 조회
|
|
*
|
|
* @param string $key 설정 키 (예: 'basic_info.shop_name')
|
|
* @param mixed $default 기본값
|
|
* @return mixed 설정값
|
|
*/
|
|
public function getSetting(string $key, mixed $default = null): mixed
|
|
{
|
|
$settings = $this->getAllSettings();
|
|
|
|
return Arr::get($settings, $key, $default);
|
|
}
|
|
|
|
/**
|
|
* 설정값 저장
|
|
*
|
|
* @param string $key 설정 키
|
|
* @param mixed $value 저장할 값
|
|
* @return bool 성공 여부
|
|
*/
|
|
public function setSetting(string $key, mixed $value): bool
|
|
{
|
|
$settings = $this->getAllSettings();
|
|
Arr::set($settings, $key, $value);
|
|
|
|
// 카테고리 추출
|
|
$parts = explode('.', $key);
|
|
$category = $parts[0];
|
|
|
|
$result = $this->saveCategorySettings($category, $settings[$category] ?? []);
|
|
|
|
// 파일 저장 후 캐시 초기화 (다음 조회 시 재계산)
|
|
$this->settings = null;
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 전체 설정 조회
|
|
*
|
|
* @return array 모든 카테고리의 설정값
|
|
*/
|
|
public function getAllSettings(): array
|
|
{
|
|
if ($this->settings !== null) {
|
|
return $this->settings;
|
|
}
|
|
|
|
// payment → order_settings 마이그레이션 (1회)
|
|
$this->migratePaymentToOrderSettings();
|
|
|
|
$defaults = $this->getDefaults();
|
|
$categories = $defaults['_meta']['categories'] ?? [];
|
|
$defaultValues = $defaults['defaults'] ?? [];
|
|
|
|
$settings = [];
|
|
foreach ($categories as $category) {
|
|
$categoryDefaults = $defaultValues[$category] ?? [];
|
|
$savedSettings = $this->loadCategorySettings($category);
|
|
$settings[$category] = array_merge($categoryDefaults, $savedSettings);
|
|
}
|
|
|
|
// 저장된 데이터를 defaults 스키마에 맞게 정규화 (하위호환성)
|
|
$settings = $this->normalizeSettingsData($settings, $defaultValues);
|
|
|
|
// language_currency.currencies 는 정수키 리스트라 array_merge 가 통째 교체 →
|
|
// defaults 에 있고 저장본에 없는 통화는 code 기준으로 보충(환율은 저장본 우선 보존).
|
|
// 관리자가 의도적으로 삭제한 게 아니라 array_merge 부작용으로 소실되던 영속성 공백 수정(U11-A).
|
|
if (isset($defaultValues['language_currency']['currencies'])) {
|
|
$settings['language_currency']['currencies'] = $this->mergeCurrenciesByCode(
|
|
$defaultValues['language_currency']['currencies'],
|
|
$settings['language_currency']['currencies'] ?? []
|
|
);
|
|
}
|
|
|
|
// 결제수단 병합 (기본 + 플러그인 필터 + 사용자 저장 설정)
|
|
if (isset($settings['order_settings'])) {
|
|
$settings['order_settings']['payment_methods'] = $this->getMergedPaymentMethods(
|
|
$settings['order_settings']['payment_methods'] ?? []
|
|
);
|
|
}
|
|
|
|
// 통화 라벨에 활성 언어팩 키 자동 보강 + symbol/flag 표준 매핑 보강
|
|
// (A1, D-CUR-4: 셀렉터가 읽는 표시 메타 / 관리자가 직접 지정한 기호는 보존)
|
|
if (isset($settings['language_currency']['currencies']) && is_array($settings['language_currency']['currencies'])) {
|
|
foreach ($settings['language_currency']['currencies'] as $idx => $currency) {
|
|
if (! empty($currency['code']) && isset($currency['name']) && is_array($currency['name'])) {
|
|
$settings['language_currency']['currencies'][$idx]['name'] = localize_catalog_field(
|
|
$currency['name'],
|
|
"sirsoft-ecommerce::settings.currencies.{$currency['code']}.name",
|
|
);
|
|
}
|
|
// 셀렉터(_currency_selector.json)가 참조하는 symbol/flag 보강
|
|
// (settings 스키마에 없는 표시 메타 — 저장 시 normalize 가 떨궈 round-trip 오염 없음)
|
|
if (! empty($currency['code'])) {
|
|
$meta = $this->currencyDisplayMeta($currency['code']);
|
|
// 관리자가 직접 지정한 기호는 보존, 없을 때만 표준 매핑으로 보충
|
|
if (empty($currency['symbol'] ?? null)) {
|
|
$settings['language_currency']['currencies'][$idx]['symbol'] = $meta['symbol'];
|
|
}
|
|
// flag 는 표시 전용 메타 — 항상 표준 매핑으로 채운다
|
|
$settings['language_currency']['currencies'][$idx]['flag'] = $meta['flag'];
|
|
}
|
|
}
|
|
}
|
|
|
|
// 배송 가능 국가 라벨에 활성 언어팩 키 자동 보강
|
|
if (isset($settings['shipping']['available_countries']) && is_array($settings['shipping']['available_countries'])) {
|
|
foreach ($settings['shipping']['available_countries'] as $idx => $country) {
|
|
if (! empty($country['code']) && isset($country['name']) && is_array($country['name'])) {
|
|
$settings['shipping']['available_countries'][$idx]['name'] = localize_catalog_field(
|
|
$country['name'],
|
|
"sirsoft-ecommerce::settings.countries.{$country['code']}.name",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->settings = $settings;
|
|
|
|
return $settings;
|
|
}
|
|
|
|
/**
|
|
* 카테고리별 설정 조회
|
|
*
|
|
* @param string $category 카테고리명
|
|
* @return array 카테고리의 설정값
|
|
*/
|
|
public function getSettings(string $category): array
|
|
{
|
|
$allSettings = $this->getAllSettings();
|
|
|
|
return $allSettings[$category] ?? [];
|
|
}
|
|
|
|
/**
|
|
* 공개 결제 설정 조회 (bank_accounts에 은행명 매핑 포함)
|
|
*
|
|
* @return array 은행명이 포함된 결제 설정
|
|
*/
|
|
public function getPublicPaymentSettings(): array
|
|
{
|
|
$orderSettings = $this->getSettings('order_settings');
|
|
|
|
if (isset($orderSettings['bank_accounts'], $orderSettings['banks'])) {
|
|
$banks = collect($orderSettings['banks']);
|
|
$orderSettings['bank_accounts'] = array_map(function ($account) use ($banks) {
|
|
$bank = $banks->firstWhere('code', $account['bank_code'] ?? '');
|
|
$account['bank_name'] = $bank['name'] ?? $account['bank_code'] ?? '';
|
|
|
|
return $account;
|
|
}, $orderSettings['bank_accounts']);
|
|
}
|
|
|
|
return $orderSettings;
|
|
}
|
|
|
|
/**
|
|
* 설정 저장
|
|
*
|
|
* @param array $settings 저장할 설정 배열
|
|
* @return bool 성공 여부
|
|
*/
|
|
public function saveSettings(array $settings): bool
|
|
{
|
|
$success = true;
|
|
$defaults = $this->getDefaults();
|
|
$defaultValues = $defaults['defaults'] ?? [];
|
|
|
|
foreach ($settings as $category => $categorySettings) {
|
|
if (str_starts_with($category, '_')) {
|
|
continue; // _meta, _tab 등 메타 정보 무시
|
|
}
|
|
|
|
// 카테고리 값이 배열이 아닌 경우 무시 (최상위 레벨 오염 데이터 방어)
|
|
if (! is_array($categorySettings)) {
|
|
continue;
|
|
}
|
|
|
|
// 분리 입력 필드 병합 처리
|
|
$processedSettings = $this->processSplitFields($category, $categorySettings);
|
|
|
|
// defaults 스키마에 맞게 정규화
|
|
$categoryDefaults = $defaultValues[$category] ?? [];
|
|
$processedSettings = $this->normalizeCategoryData($processedSettings, $categoryDefaults);
|
|
|
|
// order_settings: 결제수단 _cached_* 메타데이터 스냅샷
|
|
if ($category === 'order_settings' && isset($processedSettings['payment_methods'])) {
|
|
$processedSettings['payment_methods'] = $this->snapshotPaymentMethodMetadata(
|
|
$processedSettings['payment_methods']
|
|
);
|
|
}
|
|
|
|
if (! $this->saveCategorySettings($category, $processedSettings)) {
|
|
$success = false;
|
|
}
|
|
}
|
|
|
|
// 캐시 초기화
|
|
$this->settings = null;
|
|
|
|
return $success;
|
|
}
|
|
|
|
/**
|
|
* 은행 목록만 저장합니다.
|
|
*
|
|
* 기존 order_settings의 다른 설정은 유지하고 banks만 교체합니다.
|
|
*
|
|
* @param array $banks 은행 목록 배열
|
|
* @return bool 성공 여부
|
|
*/
|
|
public function saveBanks(array $banks): bool
|
|
{
|
|
$currentSettings = $this->loadCategorySettings('order_settings');
|
|
$currentSettings['banks'] = $banks;
|
|
|
|
$result = $this->saveCategorySettings('order_settings', $currentSettings);
|
|
|
|
// 캐시 초기화
|
|
$this->settings = null;
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 프론트엔드용 설정 조회 (민감정보 제외)
|
|
*
|
|
* frontend_schema에 따라 민감하지 않은 설정만 반환합니다.
|
|
*
|
|
* @return array 프론트엔드에 노출 가능한 설정값
|
|
*/
|
|
public function getFrontendSettings(): array
|
|
{
|
|
$defaults = $this->getDefaults();
|
|
$frontendSchema = $defaults['frontend_schema'] ?? [];
|
|
$allSettings = $this->getAllSettings();
|
|
|
|
$frontendSettings = [];
|
|
|
|
foreach ($frontendSchema as $category => $schema) {
|
|
if (! ($schema['expose'] ?? false)) {
|
|
continue;
|
|
}
|
|
|
|
$categorySettings = $allSettings[$category] ?? [];
|
|
$fields = $schema['fields'] ?? [];
|
|
|
|
if (empty($fields)) {
|
|
// fields가 없으면 전체 카테고리 노출
|
|
$frontendSettings[$category] = $categorySettings;
|
|
|
|
continue;
|
|
}
|
|
|
|
$exposedFields = [];
|
|
foreach ($fields as $fieldName => $fieldSchema) {
|
|
if ($fieldSchema['expose'] ?? false) {
|
|
$exposedFields[$fieldName] = $categorySettings[$fieldName] ?? null;
|
|
}
|
|
}
|
|
|
|
if (! empty($exposedFields)) {
|
|
$frontendSettings[$category] = $exposedFields;
|
|
}
|
|
}
|
|
|
|
// 분리 필드 확장 (프론트엔드에서 사용할 수 있도록)
|
|
$frontendSettings = $this->expandSplitFieldsForFrontend($frontendSettings);
|
|
|
|
// order_settings: bank_accounts에 은행명 추가 (프론트엔드 편의)
|
|
if (isset($frontendSettings['order_settings']['bank_accounts']) && isset($allSettings['order_settings']['banks'])) {
|
|
$banks = collect($allSettings['order_settings']['banks']);
|
|
$frontendSettings['order_settings']['bank_accounts'] = array_map(function ($account) use ($banks) {
|
|
$bank = $banks->firstWhere('code', $account['bank_code'] ?? '');
|
|
$account['bank_name'] = $bank['name'] ?? $account['bank_code'] ?? '';
|
|
|
|
return $account;
|
|
}, $frontendSettings['order_settings']['bank_accounts']);
|
|
}
|
|
|
|
return $frontendSettings;
|
|
}
|
|
|
|
/**
|
|
* 기본값 조회
|
|
*
|
|
* @return array defaults.json 내용
|
|
*/
|
|
private function getDefaults(): array
|
|
{
|
|
if ($this->defaults !== null) {
|
|
return $this->defaults;
|
|
}
|
|
|
|
$path = $this->getSettingsDefaultsPath();
|
|
if ($path === null) {
|
|
return [];
|
|
}
|
|
|
|
$content = File::get($path);
|
|
$this->defaults = json_decode($content, true) ?? [];
|
|
|
|
return $this->defaults;
|
|
}
|
|
|
|
/**
|
|
* 카테고리 설정 파일 경로 반환
|
|
*
|
|
* @param string $category 카테고리명
|
|
* @return string 설정 파일 경로
|
|
*/
|
|
private function getCategoryFilePath(string $category): string
|
|
{
|
|
return $this->getStoragePath().'/'.$category.'.json';
|
|
}
|
|
|
|
/**
|
|
* 카테고리 설정 로드
|
|
*
|
|
* @param string $category 카테고리명
|
|
* @return array 설정값
|
|
*/
|
|
private function loadCategorySettings(string $category): array
|
|
{
|
|
$path = $this->getCategoryFilePath($category);
|
|
|
|
if (! File::exists($path)) {
|
|
return [];
|
|
}
|
|
|
|
$content = File::get($path);
|
|
|
|
return json_decode($content, true) ?? [];
|
|
}
|
|
|
|
/**
|
|
* 카테고리 설정 저장
|
|
*
|
|
* @param string $category 카테고리명
|
|
* @param array $settings 설정값
|
|
* @return bool 성공 여부
|
|
*/
|
|
private function saveCategorySettings(string $category, array $settings): bool
|
|
{
|
|
$storagePath = $this->getStoragePath();
|
|
|
|
// 디렉토리 생성
|
|
if (! File::isDirectory($storagePath)) {
|
|
File::makeDirectory($storagePath, 0755, true);
|
|
}
|
|
|
|
$path = $this->getCategoryFilePath($category);
|
|
$content = json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
|
|
|
return File::put($path, $content) !== false;
|
|
}
|
|
|
|
/**
|
|
* 모듈 경로 반환
|
|
*
|
|
* 활성 디렉토리(modules/{identifier})가 존재하면 우선 사용하고,
|
|
* 존재하지 않는 경우 (pre-install / 테스트 환경) _bundled 원본을 fallback 으로 사용.
|
|
*
|
|
* @return string 모듈 디렉토리 경로
|
|
*/
|
|
private function getModulePath(): string
|
|
{
|
|
$active = base_path('modules/'.self::MODULE_IDENTIFIER);
|
|
if (is_dir($active)) {
|
|
return $active;
|
|
}
|
|
|
|
return base_path('modules/_bundled/'.self::MODULE_IDENTIFIER);
|
|
}
|
|
|
|
/**
|
|
* 설정 저장 경로 반환
|
|
*
|
|
* testing 환경에서는 운영 설정(storage/app/modules/.../settings)을 보호하기 위해
|
|
* 격리된 임시 경로를 사용합니다. 설정 저장 API를 호출하는 Feature 테스트가
|
|
* 운영 mileage.json 등을 덮어쓰는 것을 차단합니다(운영 설정 영구 보존).
|
|
*
|
|
* @return string 설정 파일 저장 디렉토리 경로
|
|
*/
|
|
private function getStoragePath(): string
|
|
{
|
|
if (app()->runningUnitTests()) {
|
|
return storage_path('framework/testing/modules/'.self::MODULE_IDENTIFIER.'/settings');
|
|
}
|
|
|
|
return storage_path('app/modules/'.self::MODULE_IDENTIFIER.'/settings');
|
|
}
|
|
|
|
/**
|
|
* 분리 입력 필드 병합 처리
|
|
*
|
|
* business_number_1, business_number_2, business_number_3 → business_number
|
|
* phone_1, phone_2, phone_3 → phone
|
|
* fax_1, fax_2, fax_3 → fax
|
|
* email_id, email_domain → email
|
|
*
|
|
* @param string $category 카테고리명
|
|
* @param array $settings 설정값
|
|
* @return array 처리된 설정값
|
|
*/
|
|
private function processSplitFields(string $category, array $settings): array
|
|
{
|
|
// language_currency 카테고리: default_currency와 currencies.is_default 동기화
|
|
if ($category === 'language_currency') {
|
|
return $this->syncCurrencyDefaults($settings);
|
|
}
|
|
|
|
if ($category !== 'basic_info') {
|
|
return $settings;
|
|
}
|
|
|
|
// 사업자등록번호 병합
|
|
if (isset($settings['business_number_1'])) {
|
|
$parts = [
|
|
$settings['business_number_1'] ?? '',
|
|
$settings['business_number_2'] ?? '',
|
|
$settings['business_number_3'] ?? '',
|
|
];
|
|
$settings['business_number'] = implode('-', array_filter($parts));
|
|
unset($settings['business_number_1'], $settings['business_number_2'], $settings['business_number_3']);
|
|
}
|
|
|
|
// 전화번호 병합
|
|
if (isset($settings['phone_1'])) {
|
|
$parts = [
|
|
$settings['phone_1'] ?? '',
|
|
$settings['phone_2'] ?? '',
|
|
$settings['phone_3'] ?? '',
|
|
];
|
|
$settings['phone'] = implode('-', array_filter($parts));
|
|
unset($settings['phone_1'], $settings['phone_2'], $settings['phone_3']);
|
|
}
|
|
|
|
// 팩스번호 병합
|
|
if (isset($settings['fax_1'])) {
|
|
$parts = [
|
|
$settings['fax_1'] ?? '',
|
|
$settings['fax_2'] ?? '',
|
|
$settings['fax_3'] ?? '',
|
|
];
|
|
$settings['fax'] = implode('-', array_filter($parts));
|
|
unset($settings['fax_1'], $settings['fax_2'], $settings['fax_3']);
|
|
}
|
|
|
|
// 이메일 병합
|
|
if (isset($settings['email_id'])) {
|
|
$id = $settings['email_id'] ?? '';
|
|
$domain = $settings['email_domain'] ?? '';
|
|
$settings['email'] = $id && $domain ? "{$id}@{$domain}" : '';
|
|
unset($settings['email_id'], $settings['email_domain'], $settings['email_domain_select']);
|
|
}
|
|
|
|
return $settings;
|
|
}
|
|
|
|
/**
|
|
* 프론트엔드용 분리 필드 확장
|
|
*
|
|
* business_number → business_number_1, business_number_2, business_number_3
|
|
* phone → phone_1, phone_2, phone_3
|
|
* fax → fax_1, fax_2, fax_3
|
|
* email → email_id, email_domain
|
|
*
|
|
* @param array $settings 설정값
|
|
* @return array 확장된 설정값
|
|
*/
|
|
private function expandSplitFieldsForFrontend(array $settings): array
|
|
{
|
|
if (! isset($settings['basic_info'])) {
|
|
return $settings;
|
|
}
|
|
|
|
$basicInfo = &$settings['basic_info'];
|
|
|
|
// 사업자등록번호 분리
|
|
if (isset($basicInfo['business_number']) && $basicInfo['business_number']) {
|
|
$parts = explode('-', $basicInfo['business_number']);
|
|
$basicInfo['business_number_1'] = $parts[0] ?? '';
|
|
$basicInfo['business_number_2'] = $parts[1] ?? '';
|
|
$basicInfo['business_number_3'] = $parts[2] ?? '';
|
|
} else {
|
|
$basicInfo['business_number_1'] = '';
|
|
$basicInfo['business_number_2'] = '';
|
|
$basicInfo['business_number_3'] = '';
|
|
}
|
|
|
|
// 전화번호 분리
|
|
if (isset($basicInfo['phone']) && $basicInfo['phone']) {
|
|
$parts = explode('-', $basicInfo['phone']);
|
|
$basicInfo['phone_1'] = $parts[0] ?? '';
|
|
$basicInfo['phone_2'] = $parts[1] ?? '';
|
|
$basicInfo['phone_3'] = $parts[2] ?? '';
|
|
} else {
|
|
$basicInfo['phone_1'] = '';
|
|
$basicInfo['phone_2'] = '';
|
|
$basicInfo['phone_3'] = '';
|
|
}
|
|
|
|
// 팩스번호 분리
|
|
if (isset($basicInfo['fax']) && $basicInfo['fax']) {
|
|
$parts = explode('-', $basicInfo['fax']);
|
|
$basicInfo['fax_1'] = $parts[0] ?? '';
|
|
$basicInfo['fax_2'] = $parts[1] ?? '';
|
|
$basicInfo['fax_3'] = $parts[2] ?? '';
|
|
} else {
|
|
$basicInfo['fax_1'] = '';
|
|
$basicInfo['fax_2'] = '';
|
|
$basicInfo['fax_3'] = '';
|
|
}
|
|
|
|
// 이메일 분리
|
|
if (isset($basicInfo['email']) && $basicInfo['email']) {
|
|
$parts = explode('@', $basicInfo['email']);
|
|
$basicInfo['email_id'] = $parts[0] ?? '';
|
|
$basicInfo['email_domain'] = $parts[1] ?? '';
|
|
} else {
|
|
$basicInfo['email_id'] = '';
|
|
$basicInfo['email_domain'] = '';
|
|
}
|
|
|
|
return $settings;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 결제수단 병합 (Centralized Payment Methods)
|
|
// ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* 기본 결제수단 정의 반환
|
|
*
|
|
* @return array 기본 내장 결제수단 배열
|
|
*/
|
|
private function getBuiltinPaymentMethods(): array
|
|
{
|
|
$defaults = $this->getDefaults();
|
|
$methods = $defaults['defaults']['order_settings']['payment_methods'] ?? [];
|
|
|
|
return array_map(function (array $method) {
|
|
$id = $method['id'];
|
|
|
|
return [
|
|
'id' => $id,
|
|
// 활성 언어팩의 다국어 키 자동 보강 (ja 등 부재 locale 자동 채움, 운영자 편집 보존)
|
|
'name' => localize_catalog_field(
|
|
$method['_cached_name'] ?? ['ko' => $id, 'en' => $id],
|
|
"sirsoft-ecommerce::settings.payment_methods.{$id}.name",
|
|
),
|
|
'description' => localize_catalog_field(
|
|
$method['_cached_description'] ?? ['ko' => '', 'en' => ''],
|
|
"sirsoft-ecommerce::settings.payment_methods.{$id}.description",
|
|
),
|
|
'icon' => $method['_cached_icon'] ?? 'circle-question',
|
|
'source' => $method['_cached_source'] ?? 'builtin',
|
|
'defaults' => [
|
|
'pg_provider' => $method['pg_provider'] ?? null,
|
|
// 능력(capability) 선언 — builtin 은 enum 에서 파생한다.
|
|
// 확장 결제수단은 PG 플러그인이 filter_available_payment_methods 훅에서
|
|
// 직접 선언한다 (enum case 가 없으므로).
|
|
'needs_pg' => $method['needs_pg'] ?? $this->builtinNeedsPg($id),
|
|
'pg_locked' => $method['pg_locked'] ?? false,
|
|
'refund_method' => $method['refund_method'] ?? $this->builtinRefundMethod($id),
|
|
'is_active' => $method['is_active'] ?? true,
|
|
'min_order_amount' => $method['min_order_amount'] ?? 0,
|
|
'stock_deduction_timing' => $method['stock_deduction_timing'] ?? 'payment_complete',
|
|
'mileage_deduction_timing' => $method['mileage_deduction_timing']
|
|
?? $this->defaultMileageDeductionTiming($id),
|
|
],
|
|
];
|
|
}, $methods);
|
|
}
|
|
|
|
/**
|
|
* builtin 결제수단의 PG 필요 여부를 enum 에서 파생합니다.
|
|
*
|
|
* @param string $id 결제수단 ID
|
|
* @return bool PG 결제창 필요 여부
|
|
*/
|
|
private function builtinNeedsPg(string $id): bool
|
|
{
|
|
return PaymentMethodEnum::tryFrom($id)?->needsPgProvider() ?? true;
|
|
}
|
|
|
|
/**
|
|
* builtin 결제수단의 환불 수단을 enum 에서 파생합니다.
|
|
*
|
|
* @param string $id 결제수단 ID
|
|
* @return string 환불 수단 값 (pg / bank / points)
|
|
*/
|
|
private function builtinRefundMethod(string $id): string
|
|
{
|
|
return match (PaymentMethodEnum::tryFrom($id)) {
|
|
PaymentMethodEnum::CARD,
|
|
PaymentMethodEnum::BANK,
|
|
PaymentMethodEnum::VBANK,
|
|
PaymentMethodEnum::PHONE => RefundMethodEnum::PG->value,
|
|
PaymentMethodEnum::POINT => RefundMethodEnum::POINTS->value,
|
|
default => RefundMethodEnum::BANK->value,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 사용 가능한 결제수단 정의 조회 (기본 + 플러그인 필터)
|
|
*
|
|
* @return array 결제수단 정의 배열
|
|
*/
|
|
private function getAvailablePaymentMethods(): array
|
|
{
|
|
$builtins = $this->getBuiltinPaymentMethods();
|
|
|
|
return HookManager::applyFilters(
|
|
'sirsoft-ecommerce.settings.filter_available_payment_methods',
|
|
$builtins
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 등록된 PG 제공자 목록을 조회합니다. (플러그인 훅 기반)
|
|
*
|
|
* PG 플러그인이 설치되면 훅을 통해 PG사 정보를 등록합니다.
|
|
*
|
|
* @return array PG 제공자 배열 [{id, name, icon, supported_methods}, ...]
|
|
*/
|
|
public function getRegisteredPgProviders(): array
|
|
{
|
|
return HookManager::applyFilters(
|
|
'sirsoft-ecommerce.payment.registered_pg_providers',
|
|
[]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 등록된 현금영수증 발급 프로바이더 목록을 조회합니다. (플러그인 훅 기반)
|
|
*
|
|
* PG 프로바이더와 독립적으로 선택 가능하다 — KG이니시스로 결제하면서
|
|
* 현금영수증만 토스페이먼츠로 발급하는 구성을 허용한다.
|
|
*
|
|
* @return array 프로바이더 배열 [{id, name, name_key, icon}, ...]
|
|
*/
|
|
public function getRegisteredCashReceiptProviders(): array
|
|
{
|
|
return HookManager::applyFilters(
|
|
'sirsoft-ecommerce.cash_receipt.registered_providers',
|
|
[]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 현재 선택된 현금영수증 발급 프로바이더 ID를 반환합니다.
|
|
*
|
|
* @return string|null 프로바이더 ID (미설정 시 null)
|
|
*/
|
|
public function getCashReceiptProvider(): ?string
|
|
{
|
|
$provider = $this->getSetting('order_settings.cash_receipt_provider');
|
|
|
|
return is_string($provider) && $provider !== '' ? $provider : null;
|
|
}
|
|
|
|
/**
|
|
* 자진발급 사용 여부를 반환합니다.
|
|
*
|
|
* 구매자가 현금영수증을 신청하지 않은 무통장 입금완료 건에 대해 사업자가
|
|
* 국세청 지정번호로 자진발급할지 여부. 기본값은 사용 안 함.
|
|
*
|
|
* @return bool 자진발급 사용 여부
|
|
*/
|
|
public function isCashReceiptSelfIssueEnabled(): bool
|
|
{
|
|
return (bool) $this->getSetting('order_settings.cash_receipt_self_issue', false);
|
|
}
|
|
|
|
/**
|
|
* 배송비 과세 정책을 반환합니다.
|
|
*
|
|
* @return ShippingFeeTaxPolicy 배송비 과세 정책 (미설정 시 안분)
|
|
*/
|
|
public function getShippingFeeTaxPolicy(): ShippingFeeTaxPolicy
|
|
{
|
|
$value = $this->getSetting('order_settings.shipping_fee_tax_policy');
|
|
|
|
return ShippingFeeTaxPolicy::fromValueOrDefault(is_string($value) ? $value : null);
|
|
}
|
|
|
|
/**
|
|
* 특정 결제수단의 설정을 조회합니다.
|
|
*
|
|
* @param string $methodId 결제수단 ID
|
|
* @return array|null 결제수단 설정 또는 null
|
|
*/
|
|
public function getPaymentMethodConfig(string $methodId): ?array
|
|
{
|
|
$methods = $this->getSetting('order_settings.payment_methods') ?? [];
|
|
|
|
return collect($methods)->firstWhere('id', $methodId);
|
|
}
|
|
|
|
/**
|
|
* 결제수단별 재고 차감 타이밍을 조회합니다.
|
|
*
|
|
* @param string $paymentMethodId 결제수단 ID
|
|
* @return string 재고 차감 타이밍 ('order_placed', 'payment_complete', 'none')
|
|
*/
|
|
public function getStockDeductionTiming(string $paymentMethodId): string
|
|
{
|
|
$config = $this->getPaymentMethodConfig($paymentMethodId);
|
|
|
|
return $config['stock_deduction_timing'] ?? 'payment_complete';
|
|
}
|
|
|
|
/**
|
|
* 결제수단별 마일리지 차감 시점을 조회합니다. (마일리지/MP06)
|
|
*
|
|
* 재고(getStockDeductionTiming)와 동형으로 결제수단별 설정을 사용한다.
|
|
* 무통장(vbank/dbank)은 입금 전 마일리지 재사용을 막기 위해 order_placed,
|
|
* PG 카드는 결제 미완료/실패 시 선차감 손실을 막기 위해 payment_complete 가 기본이다.
|
|
*
|
|
* @param string $paymentMethodId 결제수단 ID
|
|
* @return string 차감 시점 ('order_placed' | 'payment_complete')
|
|
*/
|
|
public function getMileageDeductionTiming(string $paymentMethodId): string
|
|
{
|
|
$config = $this->getPaymentMethodConfig($paymentMethodId);
|
|
|
|
return $config['mileage_deduction_timing']
|
|
?? $this->defaultMileageDeductionTiming($paymentMethodId);
|
|
}
|
|
|
|
/**
|
|
* 결제수단별 마일리지 차감 시점 기본값을 반환합니다. (마일리지/MP06)
|
|
*
|
|
* 무통장 계열(vbank/dbank)은 입금 전 재사용 차단을 위해 order_placed,
|
|
* 그 외(PG 카드 등)는 결제 미완료/실패 시 선차감 손실 방지를 위해 payment_complete.
|
|
*
|
|
* @param string $paymentMethodId 결제수단 ID
|
|
* @return string order_placed | payment_complete
|
|
*/
|
|
protected function defaultMileageDeductionTiming(string $paymentMethodId): string
|
|
{
|
|
return in_array($paymentMethodId, ['vbank', 'dbank'], true)
|
|
? 'order_placed'
|
|
: 'payment_complete';
|
|
}
|
|
|
|
/**
|
|
* 결제수단 병합 결과 조회
|
|
*
|
|
* 기본/플러그인 정의와 사용자 저장 설정을 병합합니다.
|
|
*
|
|
* @param array $savedMethods 사용자 저장 결제수단 배열
|
|
* @return array 병합된 결제수단 배열
|
|
*/
|
|
public function getMergedPaymentMethods(array $savedMethods = []): array
|
|
{
|
|
$available = $this->getAvailablePaymentMethods();
|
|
|
|
return $this->mergePaymentMethodSettings($available, $savedMethods);
|
|
}
|
|
|
|
/**
|
|
* 결제수단 정의와 사용자 설정 병합
|
|
*
|
|
* @param array $available 사용 가능한 결제수단 정의 배열
|
|
* @param array $saved 사용자 저장 설정 배열
|
|
* @return array 병합된 결제수단 배열
|
|
*/
|
|
private function mergePaymentMethodSettings(array $available, array $saved): array
|
|
{
|
|
$availableById = collect($available)->keyBy('id');
|
|
$savedById = collect($saved)->keyBy('id');
|
|
|
|
$merged = [];
|
|
|
|
// 1. 사용 가능한 결제수단: 저장된 설정과 병합
|
|
foreach ($available as $definition) {
|
|
$id = $definition['id'];
|
|
$savedItem = $savedById->get($id);
|
|
|
|
// 능력(capability) 선언은 정의(enum 파생 또는 플러그인 선언)가 SSoT — saved 로 덮지 않는다.
|
|
// 관리자가 편집하는 값이 아니며, 플러그인이 자기 결제수단의 성격(PG 필요/환불수단/PG 고정)을
|
|
// 바꾸면 그 선언이 즉시 반영되어야 한다.
|
|
$needsPg = (bool) ($definition['defaults']['needs_pg'] ?? $this->builtinNeedsPg($id));
|
|
$pgLocked = (bool) ($definition['defaults']['pg_locked'] ?? false);
|
|
$refundMethod = $definition['defaults']['refund_method'] ?? $this->builtinRefundMethod($id);
|
|
|
|
// PG 고정 수단(간편결제 등)은 특정 PG 에 종속되므로 관리자가 다른 PG 로 바꿀 수 없다.
|
|
// saved 에 남은 과거 값(예: null)이 있어도 정의의 PG 를 강제한다.
|
|
$capabilities = [
|
|
'needs_pg' => $needsPg,
|
|
'pg_locked' => $pgLocked,
|
|
'refund_method' => $refundMethod,
|
|
];
|
|
|
|
// iOS 전용 노출 플래그(애플페이 등) — 정의가 SSoT. 관리자가 편집하는 값이 아니며,
|
|
// 체크아웃 레이아웃이 비-iOS 기기에서 해당 수단을 렌더하지 않는 데 쓰인다.
|
|
if (! empty($definition['requires_ios'])) {
|
|
$capabilities['requires_ios'] = true;
|
|
}
|
|
|
|
if ($savedItem) {
|
|
$merged[] = array_merge([
|
|
'id' => $id,
|
|
'pg_provider' => $pgLocked
|
|
? ($definition['defaults']['pg_provider'] ?? null)
|
|
: ($savedItem['pg_provider'] ?? null),
|
|
'sort_order' => $savedItem['sort_order'] ?? count($merged) + 1,
|
|
'is_active' => $savedItem['is_active'] ?? $definition['defaults']['is_active'] ?? true,
|
|
'min_order_amount' => $savedItem['min_order_amount'] ?? $definition['defaults']['min_order_amount'] ?? 0,
|
|
'stock_deduction_timing' => $savedItem['stock_deduction_timing'] ?? $definition['defaults']['stock_deduction_timing'] ?? 'payment_complete',
|
|
'mileage_deduction_timing' => $savedItem['mileage_deduction_timing'] ?? $definition['defaults']['mileage_deduction_timing'] ?? $this->defaultMileageDeductionTiming($id),
|
|
'_cached_name' => $definition['name'],
|
|
'_cached_description' => $definition['description'] ?? ['ko' => '', 'en' => ''],
|
|
'_cached_icon' => $definition['icon'] ?? 'circle-question',
|
|
'_cached_brand_mark' => $definition['brand_mark'] ?? null,
|
|
'_cached_source' => $definition['source'] ?? 'builtin',
|
|
], $capabilities);
|
|
} else {
|
|
// 신규 결제수단 (기본값 적용)
|
|
$merged[] = array_merge([
|
|
'id' => $id,
|
|
'pg_provider' => $definition['defaults']['pg_provider'] ?? null,
|
|
'sort_order' => count($merged) + 1,
|
|
'is_active' => $definition['defaults']['is_active'] ?? false,
|
|
'min_order_amount' => $definition['defaults']['min_order_amount'] ?? 0,
|
|
'stock_deduction_timing' => $definition['defaults']['stock_deduction_timing'] ?? 'payment_complete',
|
|
'mileage_deduction_timing' => $definition['defaults']['mileage_deduction_timing'] ?? $this->defaultMileageDeductionTiming($id),
|
|
'_cached_name' => $definition['name'],
|
|
'_cached_description' => $definition['description'] ?? ['ko' => '', 'en' => ''],
|
|
'_cached_icon' => $definition['icon'] ?? 'circle-question',
|
|
'_cached_brand_mark' => $definition['brand_mark'] ?? null,
|
|
'_cached_source' => $definition['source'] ?? 'builtin',
|
|
], $capabilities);
|
|
}
|
|
}
|
|
|
|
// 2. 고아 항목: 저장은 되어있지만 현재 available에 없는 결제수단
|
|
foreach ($saved as $savedItem) {
|
|
$id = $savedItem['id'] ?? '';
|
|
if ($id && ! $availableById->has($id)) {
|
|
$merged[] = array_merge($savedItem, [
|
|
'_orphaned' => true,
|
|
]);
|
|
}
|
|
}
|
|
|
|
// sort_order 기준 정렬 (고아 항목은 끝에)
|
|
usort($merged, function ($a, $b) {
|
|
$aOrphaned = $a['_orphaned'] ?? false;
|
|
$bOrphaned = $b['_orphaned'] ?? false;
|
|
if ($aOrphaned !== $bOrphaned) {
|
|
return $aOrphaned ? 1 : -1;
|
|
}
|
|
|
|
return ($a['sort_order'] ?? PHP_INT_MAX) - ($b['sort_order'] ?? PHP_INT_MAX);
|
|
});
|
|
|
|
return $merged;
|
|
}
|
|
|
|
/**
|
|
* 저장 시 결제수단 _cached_* 메타데이터 스냅샷
|
|
*
|
|
* 현재 available 정의에서 다국어 이름/아이콘을 캐싱합니다.
|
|
*
|
|
* @param array $savedMethods 저장할 결제수단 배열
|
|
* @return array _cached_* 필드가 갱신된 결제수단 배열
|
|
*/
|
|
private function snapshotPaymentMethodMetadata(array $savedMethods): array
|
|
{
|
|
$available = $this->getAvailablePaymentMethods();
|
|
$availableById = collect($available)->keyBy('id');
|
|
|
|
foreach ($savedMethods as $index => $method) {
|
|
$id = $method['id'] ?? '';
|
|
if (isset($availableById[$id])) {
|
|
$def = $availableById[$id];
|
|
$savedMethods[$index]['_cached_name'] = $def['name'] ?? $method['_cached_name'] ?? null;
|
|
$savedMethods[$index]['_cached_description'] = $def['description'] ?? $method['_cached_description'] ?? null;
|
|
$savedMethods[$index]['_cached_icon'] = $def['icon'] ?? $method['_cached_icon'] ?? null;
|
|
$savedMethods[$index]['_cached_brand_mark'] = $def['brand_mark'] ?? $method['_cached_brand_mark'] ?? null;
|
|
$savedMethods[$index]['_cached_source'] = $def['source'] ?? $method['_cached_source'] ?? 'builtin';
|
|
}
|
|
// 고아 항목은 기존 _cached_* 유지
|
|
|
|
// _orphaned 플래그는 저장하지 않음 (런타임 전용)
|
|
unset($savedMethods[$index]['_orphaned']);
|
|
}
|
|
|
|
return $savedMethods;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 데이터 마이그레이션
|
|
// ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* payment → order_settings 자동 마이그레이션 (1회)
|
|
*
|
|
* payment.json이 존재하고 order_settings.json이 없을 때
|
|
* 기존 데이터를 order_settings 구조로 변환합니다.
|
|
*/
|
|
private function migratePaymentToOrderSettings(): void
|
|
{
|
|
$paymentPath = $this->getCategoryFilePath('payment');
|
|
$orderSettingsPath = $this->getCategoryFilePath('order_settings');
|
|
|
|
// payment.json이 존재하고 order_settings.json이 없을 때만 마이그레이션
|
|
if (! File::exists($paymentPath) || File::exists($orderSettingsPath)) {
|
|
return;
|
|
}
|
|
|
|
$paymentData = json_decode(File::get($paymentPath), true) ?? [];
|
|
$orderSettings = [];
|
|
|
|
// banks 그대로 이전
|
|
if (isset($paymentData['banks'])) {
|
|
$orderSettings['banks'] = $paymentData['banks'];
|
|
}
|
|
|
|
// bank_accounts 이전 또는 dbank_* 단일 필드에서 변환
|
|
if (isset($paymentData['bank_accounts'])) {
|
|
$orderSettings['bank_accounts'] = $paymentData['bank_accounts'];
|
|
} elseif (isset($paymentData['dbank_bank_code'])) {
|
|
$orderSettings['bank_accounts'] = [
|
|
[
|
|
'bank_code' => $paymentData['dbank_bank_code'] ?? '',
|
|
'account_number' => $paymentData['dbank_account_number'] ?? '',
|
|
'account_holder' => $paymentData['dbank_account_holder'] ?? '',
|
|
'is_active' => true,
|
|
'is_default' => true,
|
|
],
|
|
];
|
|
}
|
|
|
|
// 숫자/불리언 설정 이전
|
|
$migrateFields = [
|
|
'auto_cancel_expired', 'auto_cancel_days',
|
|
'cart_expiry_days', 'stock_restore_on_cancel',
|
|
];
|
|
|
|
foreach ($migrateFields as $field) {
|
|
if (array_key_exists($field, $paymentData)) {
|
|
$orderSettings[$field] = $paymentData[$field];
|
|
}
|
|
}
|
|
|
|
// payment_methods는 defaults에서 가져옴 (enable_vbank 등은 무시)
|
|
// payment.json은 백업용으로 보존 (삭제하지 않음)
|
|
$this->saveCategorySettings('order_settings', $orderSettings);
|
|
}
|
|
|
|
/**
|
|
* 캐시 초기화
|
|
*/
|
|
public function clearCache(): void
|
|
{
|
|
$this->defaults = null;
|
|
$this->settings = null;
|
|
}
|
|
|
|
/**
|
|
* 통화 코드의 표시 메타(기호·국기)를 반환합니다. (A1 — D-CUR-4)
|
|
*
|
|
* settings 스키마에는 code/name/exchange_rate/rounding/decimal_places/is_default 만 있고
|
|
* symbol/flag 가 없으므로, 셀렉터(_currency_selector.json)가 참조하는 표시 메타를 표준 매핑으로
|
|
* 보강합니다. 미정의 코드는 symbol=코드, flag='' 폴백.
|
|
*
|
|
* @param string $code 통화 코드 (예: 'KRW')
|
|
* @return array{symbol: string, flag: string} 기호·국기 이모지
|
|
*/
|
|
private function currencyDisplayMeta(string $code): array
|
|
{
|
|
$map = [
|
|
'KRW' => ['symbol' => '₩', 'flag' => '🇰🇷'],
|
|
'USD' => ['symbol' => '$', 'flag' => '🇺🇸'],
|
|
'JPY' => ['symbol' => '¥', 'flag' => '🇯🇵'],
|
|
// CNY 는 JPY 와 동일한 ¥ 기호 충돌을 피하기 위해 元 사용 (위안화 식별성 확보)
|
|
'CNY' => ['symbol' => '元', 'flag' => '🇨🇳'],
|
|
'EUR' => ['symbol' => '€', 'flag' => '🇪🇺'],
|
|
'GBP' => ['symbol' => '£', 'flag' => '🇬🇧'],
|
|
];
|
|
|
|
return $map[$code] ?? ['symbol' => $code, 'flag' => ''];
|
|
}
|
|
|
|
/**
|
|
* 통화 목록을 code 기준으로 병합합니다. (U11-A 영속성 공백 수정)
|
|
*
|
|
* language_currency.currencies 는 정수키 리스트라 PHP array_merge 가 병합이 아니라
|
|
* 통째 교체를 수행합니다. 관리자가 일부 통화를 빼고 저장하면 defaults 의 통화가 영구
|
|
* 소실되던 문제를, 저장본에 없는 defaults 통화를 code 기준으로 보충해 해결합니다.
|
|
*
|
|
* - 저장본에 있는 통화: 저장본(환율 포함) 채택 (관리자 편집 보존)
|
|
* - 저장본에 없는 defaults 통화: defaults 항목 보충 (소실 방지)
|
|
* - 저장본에만 있는(관리자 신규 추가) 통화: 그대로 보존
|
|
*
|
|
* @param array $defaults defaults.json 의 통화 목록
|
|
* @param array $saved 저장본 통화 목록
|
|
* @return array code 기준으로 병합된 통화 목록
|
|
*/
|
|
private function mergeCurrenciesByCode(array $defaults, array $saved): array
|
|
{
|
|
// defaults 를 code 인덱스로 매핑 (필드 보충용)
|
|
$defaultsByCode = [];
|
|
foreach ($defaults as $defaultCurrency) {
|
|
$code = $defaultCurrency['code'] ?? null;
|
|
if ($code !== null) {
|
|
$defaultsByCode[$code] = $defaultCurrency;
|
|
}
|
|
}
|
|
|
|
// 저장본을 code 인덱스로 매핑 (저장본 우선)
|
|
$savedByCode = [];
|
|
foreach ($saved as $currency) {
|
|
$code = $currency['code'] ?? null;
|
|
if ($code !== null) {
|
|
$savedByCode[$code] = $currency;
|
|
}
|
|
}
|
|
|
|
$merged = [];
|
|
$usedCodes = [];
|
|
|
|
// defaults 순회: 저장본에 있으면 저장본 채택, 없으면 defaults 보충
|
|
foreach ($defaults as $defaultCurrency) {
|
|
$code = $defaultCurrency['code'] ?? null;
|
|
if ($code === null) {
|
|
continue;
|
|
}
|
|
$merged[] = $this->backfillBaseUnit($savedByCode[$code] ?? $defaultCurrency, $defaultsByCode[$code] ?? []);
|
|
$usedCodes[$code] = true;
|
|
}
|
|
|
|
// 저장본에만 있는(관리자 신규 추가) 통화 보존
|
|
foreach ($saved as $currency) {
|
|
$code = $currency['code'] ?? null;
|
|
if ($code !== null && ! isset($usedCodes[$code])) {
|
|
$merged[] = $this->backfillBaseUnit($currency, $defaultsByCode[$code] ?? []);
|
|
}
|
|
}
|
|
|
|
return $merged;
|
|
}
|
|
|
|
/**
|
|
* 통화 항목에 base_unit 이 없으면 defaults 또는 폴백(KRW=1000, JPY=100, 그 외=1)으로 보충합니다.
|
|
*
|
|
* 기존 저장본(base_unit 미저장)이 설정 화면에서 base_unit 을 표시·편집할 수 있도록 보강합니다.
|
|
* 런타임 환산은 CurrencyConversionService 폴백으로도 안전하나, 영속본 일관성을 위해 보충합니다.
|
|
*
|
|
* @param array $currency 통화 항목
|
|
* @param array $default 같은 code 의 defaults 항목
|
|
* @return array base_unit 이 보충된 통화 항목
|
|
*/
|
|
private function backfillBaseUnit(array $currency, array $default): array
|
|
{
|
|
if (isset($currency['base_unit'])) {
|
|
return $currency;
|
|
}
|
|
|
|
$fallback = ['KRW' => 1000, 'JPY' => 100];
|
|
$code = $currency['code'] ?? '';
|
|
$currency['base_unit'] = $default['base_unit'] ?? ($fallback[$code] ?? 1);
|
|
|
|
return $currency;
|
|
}
|
|
|
|
/**
|
|
* 기본 통화 설정 동기화
|
|
*
|
|
* default_currency 값에 따라 currencies 배열의 is_default 플래그를 동기화합니다.
|
|
* 기본 통화의 exchange_rate는 null로 설정됩니다.
|
|
*
|
|
* @param array $settings language_currency 설정값
|
|
* @return array 동기화된 설정값
|
|
*/
|
|
private function syncCurrencyDefaults(array $settings): array
|
|
{
|
|
$defaultCurrency = $settings['default_currency'] ?? null;
|
|
$currencies = $settings['currencies'] ?? [];
|
|
|
|
if (! $defaultCurrency || empty($currencies)) {
|
|
return $settings;
|
|
}
|
|
|
|
// 각 통화의 is_default 플래그를 default_currency 값에 맞게 업데이트
|
|
foreach ($currencies as $index => $currency) {
|
|
$isDefault = ($currency['code'] ?? '') === $defaultCurrency;
|
|
$currencies[$index]['is_default'] = $isDefault;
|
|
|
|
// 기본 통화는 환율이 필요 없음 (자기 자신 기준)
|
|
if ($isDefault) {
|
|
$currencies[$index]['exchange_rate'] = null;
|
|
}
|
|
}
|
|
|
|
$settings['currencies'] = $currencies;
|
|
|
|
return $settings;
|
|
}
|
|
}
|