마이페이지·비회원 주문 상세에서 상품별 배송정책명과 개별 배송비 줄이 아무 안내 없이 통째로 사라졌다. 주문에 보관하는 배송정책 스냅샷이 항목 목록과 배송지 메타를 한 배열에 섞어 담고 있어, PHP 배열이 non-sequential 이 되면서 json_encode 가 리스트가 아니라 객체로 직렬화했기 때문이다. 서버 소비자는 정수 키 판별로 관용 처리해 무사했고 배열을 전제한 화면만 죽었다. 한 컬럼에 두 의미를 담은 구조 자체를 items + address 로 분리했다. 이미 저장된 주문은 화면만 고쳐서는 낫지 않으므로 업그레이드 스텝으로 함께 정규화한다.
1999 lines
94 KiB
PHP
1999 lines
94 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Ecommerce\Services;
|
|
|
|
use App\Extension\HookManager;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Modules\Sirsoft\Ecommerce\DTO\CalculationInput;
|
|
use Modules\Sirsoft\Ecommerce\DTO\CalculationItem;
|
|
use Modules\Sirsoft\Ecommerce\DTO\OrderCalculationResult;
|
|
use Modules\Sirsoft\Ecommerce\DTO\ShippingAddress;
|
|
use Modules\Sirsoft\Ecommerce\Enums\DeliveryMemoPresetEnum;
|
|
use Modules\Sirsoft\Ecommerce\Enums\DeviceTypeEnum;
|
|
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
|
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
|
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
|
use Modules\Sirsoft\Ecommerce\Enums\SequenceType;
|
|
use Modules\Sirsoft\Ecommerce\Enums\ShippingStatusEnum;
|
|
use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException;
|
|
use Modules\Sirsoft\Ecommerce\Exceptions\MileageValidationException;
|
|
use Modules\Sirsoft\Ecommerce\Exceptions\OrderAmountChangedException;
|
|
use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException;
|
|
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
|
|
use Modules\Sirsoft\Ecommerce\Exceptions\UnsupportedPaymentCurrencyException;
|
|
use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector;
|
|
use Modules\Sirsoft\Ecommerce\Models\Order;
|
|
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
|
|
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
|
|
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
|
|
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CartRepositoryInterface;
|
|
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\EcommerceUserProfileRepositoryInterface;
|
|
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
|
|
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductOptionRepositoryInterface;
|
|
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
|
|
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ShippingTypeRepositoryInterface;
|
|
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
|
|
use Modules\Sirsoft\Ecommerce\Support\VatCalculator;
|
|
|
|
/**
|
|
* 주문 처리 서비스
|
|
*
|
|
* 임시 주문(TempOrder)을 실제 주문(Order)으로 변환합니다.
|
|
* 주문 상태 흐름: pending_order → pending_payment → payment_complete → ...
|
|
*/
|
|
class OrderProcessingService
|
|
{
|
|
/** 입금기한 기본값(일) — 설정이 비어 있거나 사용할 수 없는 값일 때 적용 */
|
|
private const AUTO_CANCEL_DAYS_DEFAULT = 3;
|
|
|
|
/** 입금기한 허용 하한(일) — 설정(limits)에 값이 없을 때의 fallback */
|
|
private const AUTO_CANCEL_DAYS_MIN_FALLBACK = 1;
|
|
|
|
/** 입금기한 허용 상한(일) — 설정(limits)에 값이 없을 때의 fallback */
|
|
private const AUTO_CANCEL_DAYS_MAX_FALLBACK = 30;
|
|
|
|
public function __construct(
|
|
protected OrderRepositoryInterface $orderRepository,
|
|
protected TempOrderService $tempOrderService,
|
|
protected OrderCalculationService $orderCalculationService,
|
|
protected CurrencyConversionService $currencyConversionService,
|
|
protected ProductRepositoryInterface $productRepository,
|
|
protected ProductOptionRepositoryInterface $productOptionRepository,
|
|
protected SequenceService $sequenceService,
|
|
protected EcommerceSettingsService $settingsService,
|
|
protected StockService $stockService,
|
|
protected UserAddressService $userAddressService,
|
|
protected CartRepositoryInterface $cartRepository,
|
|
protected PurchaseEligibilityService $purchaseEligibilityService,
|
|
protected UserMileageService $userMileageService,
|
|
protected ShippingTypeRepositoryInterface $shippingTypeRepository,
|
|
protected ShippingPolicyResolver $shippingPolicyResolver
|
|
) {}
|
|
|
|
/**
|
|
* 임시 주문에서 실제 주문 생성
|
|
*
|
|
* @param TempOrder $tempOrder 임시 주문
|
|
* @param array $ordererInfo 주문자 정보 (name, phone, email)
|
|
* @param array $shippingInfo 배송지 정보
|
|
* @param string $paymentMethod 결제 수단 (card, vbank, dbank 등)
|
|
* @param float $expectedTotalAmount 프론트엔드 결제예정금액 (금액 검증용)
|
|
* @param string|null $shippingMemo 배송 메모
|
|
* @param string|null $depositorName 입금자명 (무통장입금 시)
|
|
* @param array|null $dbankInfo 무통장 수동입금 정보 (dbank 결제 시)
|
|
* @param string|null $guestLookupPassword 비회원 조회 비밀번호 평문 (해시로 저장, 회원 주문은 null)
|
|
* @return Order 생성된 주문
|
|
*
|
|
* @throws OrderAmountChangedException 재계산 금액 변동 시
|
|
* @throws PaymentAmountMismatchException 프론트엔드 전달 금액 불일치 시
|
|
* @throws \Exception 계산 검증 오류 시
|
|
*/
|
|
public function createFromTempOrder(
|
|
TempOrder $tempOrder,
|
|
array $ordererInfo,
|
|
array $shippingInfo,
|
|
string $paymentMethod,
|
|
float $expectedTotalAmount,
|
|
?string $shippingMemo = null,
|
|
?string $depositorName = null,
|
|
?array $dbankInfo = null,
|
|
?string $guestLookupPassword = null
|
|
): Order {
|
|
// 생성 전 훅
|
|
HookManager::doAction('sirsoft-ecommerce.order.before_create', $tempOrder, $ordererInfo, $shippingInfo, $paymentMethod);
|
|
|
|
// 구매 대상 제한 최종 차단 (회원/비회원 공통, 프론트 우회 불가 방어선)
|
|
$this->validatePurchaseEligibility($tempOrder);
|
|
|
|
// 배송국가 미지원 최종 차단 (D1 — 혼재 시 전체 차단, 프론트 우회 불가 방어선)
|
|
$this->validateShippingCountrySupported($tempOrder, $shippingInfo['country_code'] ?? null);
|
|
|
|
// 저장된 파라미터로 재계산 수행
|
|
$calculationInput = $this->buildCalculationInputFromTempOrder($tempOrder);
|
|
$calculationResult = $this->orderCalculationService->calculate($calculationInput);
|
|
|
|
// 재계산 검증 오류 확인 (쿠폰 만료, 재고 변동 등)
|
|
if ($calculationResult->hasValidationErrors()) {
|
|
throw new OrderProcessingException(__('sirsoft-ecommerce::exceptions.order_calculation_validation_failed'));
|
|
}
|
|
|
|
// 재계산 금액과 저장된 금액 비교 → 변동 시 주문 차단
|
|
$storedFinalAmount = $tempOrder->getFinalAmount();
|
|
$recalculatedFinalAmount = $calculationResult->summary->finalAmount ?? 0;
|
|
if ($storedFinalAmount !== $recalculatedFinalAmount) {
|
|
Log::warning('주문 재계산 금액 차이로 주문 차단', [
|
|
'temp_order_id' => $tempOrder->id,
|
|
'stored_amount' => $storedFinalAmount,
|
|
'recalculated_amount' => $recalculatedFinalAmount,
|
|
]);
|
|
throw new OrderAmountChangedException($storedFinalAmount, $recalculatedFinalAmount);
|
|
}
|
|
|
|
// ⚠️ 프론트엔드 전달 금액과 서버 재계산 금액 검증
|
|
$this->validateOrderAmount($calculationResult, $expectedTotalAmount);
|
|
|
|
// 마일리지 사용 정책 최종 재검증 (임시주문 생성 이후의 설정 변경·임시주문 조작 차단).
|
|
// 임시주문 단계에서 이미 통과한 값이라도 결제 확정 직전 한 번 더 판정한다.
|
|
$this->validateMileageUsagePolicy($tempOrder, $calculationResult);
|
|
|
|
// 통화 스냅샷 생성
|
|
$currencySnapshot = $this->buildCurrencySnapshot();
|
|
|
|
// 초기 주문 상태 결정.
|
|
// 결제할 금액(finalAmount)이 0원이면 비현금 수단(마일리지/예치금 등)으로 전액 충당된 것이므로
|
|
// PG/입금 절차 없이 즉시 결제완료. 판정 기준은 결제수단·충당 수단 종류와 무관한 "잔여 결제액 0".
|
|
$finalAmount = (int) ($calculationResult->summary->finalAmount ?? 0);
|
|
$isZeroPayable = $finalAmount <= 0;
|
|
$initialStatus = $this->determineInitialStatus($paymentMethod, $finalAmount);
|
|
|
|
// 실제 결제(잔여 결제액 > 0)가 필요한 주문은 결제 통화로 청구 가능한지 주문 생성 전에 검증한다.
|
|
// 결제 통화의 환율이 미설정이거나 환산 결과가 0 이하면 PG/입금 청구가 불가하므로
|
|
// 명확한 예외로 차단한다(주문 부산물 생성 방지). 전액 비현금 충당(0원)은 청구가 없어 제외.
|
|
if (! $isZeroPayable) {
|
|
$this->assertPaymentCurrencyChargeable($currencySnapshot, $finalAmount);
|
|
}
|
|
|
|
$order = DB::transaction(function () use (
|
|
$tempOrder,
|
|
$ordererInfo,
|
|
$shippingInfo,
|
|
$paymentMethod,
|
|
$shippingMemo,
|
|
$depositorName,
|
|
$dbankInfo,
|
|
$calculationResult,
|
|
$initialStatus,
|
|
$currencySnapshot,
|
|
$guestLookupPassword,
|
|
$isZeroPayable
|
|
) {
|
|
// 주문 생성
|
|
$order = $this->createOrder($tempOrder, $calculationResult, $initialStatus, $currencySnapshot, $guestLookupPassword, $shippingInfo);
|
|
|
|
// 주문 옵션 생성 (배송 정보 연결을 위해 옵션 ID 매핑 반환)
|
|
$createdOptions = $this->createOrderOptions($order, $tempOrder, $calculationResult, $currencySnapshot);
|
|
|
|
// 주문 주소 생성 (주문자 + 배송지)
|
|
$this->createOrderAddresses($order, $ordererInfo, $shippingInfo, $shippingMemo);
|
|
|
|
// 결제 정보 생성
|
|
$this->createOrderPayment($order, $paymentMethod, $depositorName, $dbankInfo, $calculationResult, $currencySnapshot, $ordererInfo);
|
|
|
|
// 배송 정보 생성 (주문 옵션과 연결)
|
|
$this->createOrderShippings($order, $tempOrder, $calculationResult, $currencySnapshot, $createdOptions);
|
|
|
|
// 쿠폰 사용 처리 (재계산 결과에서 적용된 쿠폰 ID 추출)
|
|
$appliedCouponIds = $calculationResult->getAppliedCouponIds();
|
|
if (! empty($appliedCouponIds)) {
|
|
HookManager::doAction('sirsoft-ecommerce.coupon.use', $appliedCouponIds, $order);
|
|
}
|
|
|
|
// 마일리지 사용 처리 (TempOrder에 저장된 사용 마일리지)
|
|
// 차감 시점은 결제수단별 설정(payment_methods.*.mileage_deduction_timing) 으로 분기한다:
|
|
// order_placed(무통장 기본) 면 주문 생성 시 차감, payment_complete(카드 기본) 면 결제완료
|
|
// (completePayment) 시점으로 미룬다 — PG 결제 미완료/실패 주문에서 고객 마일리지가 선차감되어
|
|
// 영구 손실되는 것을 방지하고, 무통장은 입금 전 재사용을 차단한다.
|
|
// 단, 결제액 0원(전액 마일리지/예치금) 주문은 PG/입금 콜백이 없어 이 트랜잭션에서 즉시
|
|
// 결제완료 확정되므로 타이밍 설정과 무관하게 항상 생성 시점에 차감한다.
|
|
$usedPoints = $tempOrder->getUsedPoints();
|
|
$mileageTiming = $this->settingsService->getMileageDeductionTiming($paymentMethod);
|
|
if ($usedPoints > 0 && ($isZeroPayable || $mileageTiming === 'order_placed')) {
|
|
$this->deductMileageForOrder($order, $usedPoints);
|
|
}
|
|
|
|
// 재고 차감 + 장바구니 처리 (order_placed 타이밍: 트랜잭션 내부에서 실행)
|
|
$timing = $this->settingsService->getStockDeductionTiming($paymentMethod);
|
|
if ($timing === 'order_placed') {
|
|
$this->stockService->deductStock($order->load('options'));
|
|
$this->clearOrderedCartItems($order);
|
|
}
|
|
|
|
// 결제액 0원(전액 비현금 충당: 마일리지/예치금 등): PG/입금 콜백이 없으므로
|
|
// 트랜잭션 내에서 즉시 결제완료 확정. 충당 차감(위 mileage.use 등)과 동일 트랜잭션이라 원자성 보장.
|
|
if ($isZeroPayable) {
|
|
$this->finalizeZeroAmountPayment($order);
|
|
}
|
|
|
|
return $order;
|
|
});
|
|
|
|
// 생성 후 훅
|
|
HookManager::doAction('sirsoft-ecommerce.order.after_create', $order);
|
|
|
|
// 관리자 신규주문 알림 발송 시점 분기 (order.after_admin_notify 전용 훅).
|
|
// - 무통장/비-PG 주문: 주문 생성 시점에 발송(입금 전이라도 접수 알림이 정상).
|
|
// - 카드(PG) 주문: 이 시점은 pending_order(결제 전)라 발송하지 않는다. 결제완료(completePayment)
|
|
// 시점에 발화한다 — 결제 미완료/이탈 주문의 오발송 방지.
|
|
// - 0원(전액 비현금) 주문: 아래 결제완료 훅 경로에서 함께 발화한다.
|
|
if (! $isZeroPayable && ! $this->orderRequiresPgPayment($order)) {
|
|
HookManager::doAction('sirsoft-ecommerce.order.after_admin_notify', $order);
|
|
}
|
|
|
|
// 결제액 0원 주문은 결제완료 훅을 발화해 적립/알림/구매확정 후속을 정상 연결한다.
|
|
// (PG 결제는 별도 콜백에서 completePayment 가 동일 훅을 발화)
|
|
if ($isZeroPayable) {
|
|
$this->fireZeroAmountPaymentCompleteHooks($order);
|
|
}
|
|
|
|
return $order;
|
|
}
|
|
|
|
/**
|
|
* 주문이 PG 결제창을 거쳐야 하는지 판정합니다 (관리자 알림 시점 분기용).
|
|
*
|
|
* 결제수단이 PG 공급자를 요구하고 잔여 결제액이 양수면 PG 결제 주문이다.
|
|
* 컨트롤러의 requiresPg 판정과 동일 기준(결제수단·공급자·잔여액)을 따른다.
|
|
*
|
|
* @param Order $order 주문(payment 로드 필요)
|
|
* @return bool PG 결제 필요 여부
|
|
*/
|
|
protected function orderRequiresPgPayment(Order $order): bool
|
|
{
|
|
if (! $order->payment || ! $order->payment->needsPgProvider()) {
|
|
return false;
|
|
}
|
|
|
|
$pgProvider = $this->determinePgProvider($order->payment->paymentMethodId());
|
|
|
|
return ! in_array($pgProvider, ['manual', 'internal', 'none'], true)
|
|
&& (float) $order->total_due_amount > 0;
|
|
}
|
|
|
|
/**
|
|
* 결제액 0원(전액 비현금 충당) 주문의 결제완료 DB 상태를 확정합니다 (트랜잭션 내부 호출).
|
|
*
|
|
* @param Order $order 대상 주문
|
|
*/
|
|
protected function finalizeZeroAmountPayment(Order $order): void
|
|
{
|
|
$order->update([
|
|
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
|
'paid_at' => Carbon::now(),
|
|
'total_paid_amount' => 0,
|
|
'total_due_amount' => 0,
|
|
]);
|
|
|
|
// 옵션 상태도 결제완료로 동기화 (PG 콜백이 없는 0원 주문도 옵션이 PENDING_ORDER 에 갇히지 않도록)
|
|
$this->syncOptionStatuses($order, OrderStatusEnum::PAYMENT_COMPLETE);
|
|
|
|
$order->payment()->update([
|
|
'payment_status' => PaymentStatusEnum::PAID,
|
|
'paid_at' => Carbon::now(),
|
|
'paid_amount_local' => 0,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 전액 마일리지 결제 주문의 결제완료 후속 훅을 발화합니다 (트랜잭션 외부 호출).
|
|
*
|
|
* completePayment 의 훅 순서를 그대로 따라 적립(mileage.earn)·결제완료·구매확정 후속을 연결한다.
|
|
*
|
|
* @param Order $order 대상 주문
|
|
*/
|
|
protected function fireZeroAmountPaymentCompleteHooks(Order $order): void
|
|
{
|
|
HookManager::doAction('sirsoft-ecommerce.order.before_payment_complete', $order, []);
|
|
|
|
// 마일리지 적립 (계산된 적립 예정액이 있을 때만)
|
|
if ($order->total_earned_points_amount > 0) {
|
|
HookManager::doAction('sirsoft-ecommerce.mileage.earn', $order->total_earned_points_amount, $order);
|
|
}
|
|
|
|
HookManager::doAction('sirsoft-ecommerce.order.after_payment_complete', $order);
|
|
HookManager::doAction('sirsoft-ecommerce.order.after_confirm', $order);
|
|
|
|
// 0원 주문은 생성 시점에 발송하지 않으므로 결제완료(즉시 확정) 시점에 관리자 알림 발화
|
|
HookManager::doAction('sirsoft-ecommerce.order.after_admin_notify', $order);
|
|
}
|
|
|
|
/**
|
|
* 초기 주문 상태 결정
|
|
*
|
|
* @param string $paymentMethod 결제 수단
|
|
* @param int $finalAmount 최종 결제액(마일리지 차감 후). 0 이면 전액 마일리지 결제.
|
|
*/
|
|
protected function determineInitialStatus(string $paymentMethod, int $finalAmount = 0): OrderStatusEnum
|
|
{
|
|
// 전액 마일리지 결제(결제액 0원)는 PG/입금 절차가 없으므로 즉시 결제완료로 시작.
|
|
// 결제수단 선택은 유지하되(주문서에 기록), 외부 결제 콜백을 기다리지 않는다.
|
|
if ($finalAmount <= 0) {
|
|
return OrderStatusEnum::PAYMENT_COMPLETE;
|
|
}
|
|
|
|
// 무통장입금(vbank, dbank)은 결제대기 상태로 시작
|
|
if (in_array($paymentMethod, [PaymentMethodEnum::VBANK->value, PaymentMethodEnum::DBANK->value])) {
|
|
return OrderStatusEnum::PENDING_PAYMENT;
|
|
}
|
|
|
|
// 그 외 (PG 결제 등)는 주문대기로 시작 후 결제 완료 시 상태 변경
|
|
return OrderStatusEnum::PENDING_ORDER;
|
|
}
|
|
|
|
/**
|
|
* 주문 금액 검증
|
|
*
|
|
* 프론트엔드 결제예정금액과 서버 재계산 금액을 비교합니다.
|
|
*
|
|
* @param OrderCalculationResult $calculationResult 계산 결과
|
|
* @param float $expectedAmount 프론트엔드 결제예정금액
|
|
*
|
|
* @throws PaymentAmountMismatchException 금액 불일치 시
|
|
*/
|
|
protected function validateOrderAmount(OrderCalculationResult $calculationResult, float $expectedAmount): void
|
|
{
|
|
// finalAmount: 최종 지불금액 (마일리지 차감 후 실제 결제 금액).
|
|
// finalAmount 가 0 이면 "전액 마일리지 결제"라는 유효한 값이므로 paymentAmount 로 폴백하지 않는다.
|
|
// (?: 는 0 을 falsy 로 보아 폴백 → 전액 마일리지 주문을 금액 불일치로 오차단하는 버그가 있었음)
|
|
$summary = $calculationResult->summary;
|
|
$actualAmount = $summary->finalAmount ?? ($summary->paymentAmount ?? 0);
|
|
|
|
// 정확 일치 검증 (소수점 이하 2자리까지)
|
|
$expectedRounded = round($expectedAmount, 2);
|
|
$actualRounded = round($actualAmount, 2);
|
|
|
|
if ($expectedRounded !== $actualRounded) {
|
|
throw new PaymentAmountMismatchException($expectedAmount, $actualAmount, [
|
|
'user_id' => Auth::id(),
|
|
'calculation_summary' => [
|
|
'subtotal' => $calculationResult->summary->subtotal ?? 0,
|
|
'total_discount' => $calculationResult->summary->totalDiscount ?? 0,
|
|
'shipping' => $calculationResult->summary->totalShipping ?? 0,
|
|
'payment_amount' => $actualAmount,
|
|
],
|
|
'timestamp' => now()->toIso8601String(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 활성(결제·표시) 통화를 결정합니다. (§A2-b·§A3-b 공용 SSoT)
|
|
*
|
|
* 우선순위:
|
|
* 1) 로그인 유저의 영속 통화(EcommerceUserProfile.preferred_currency) — §A3 에서 주입
|
|
* 2) X-Currency 요청 헤더(비로그인/세션 표시)
|
|
* 3) base(default_currency) 폴백
|
|
*
|
|
* 게스트 체크아웃(비로그인)은 유저 컨텍스트 부재 → 헤더/base 로 안전 폴백한다.
|
|
*
|
|
* @param string $baseCurrency 기본 통화(폴백)
|
|
* @return string 결정된 통화 코드
|
|
*/
|
|
protected function resolveActiveCurrency(string $baseCurrency): string
|
|
{
|
|
// 1순위: 로그인 유저의 영속 통화 (§A3 user-profile)
|
|
$persisted = $this->resolvePersistedUserCurrency();
|
|
if ($persisted !== null && $persisted !== '') {
|
|
return $persisted;
|
|
}
|
|
|
|
// 2순위: X-Currency 헤더 (비로그인/세션 표시) → 3순위: base 폴백
|
|
return request()->header('X-Currency', $baseCurrency) ?: $baseCurrency;
|
|
}
|
|
|
|
/**
|
|
* 로그인 유저의 영속 통화를 반환합니다. (§A3-b)
|
|
*
|
|
* EcommerceUserProfile.preferred_currency 를 조회한다. 비로그인(게스트 체크아웃)이면
|
|
* 유저 컨텍스트가 없어 null 을 반환해 헤더/base 폴백 경로를 유지한다(게스트 안전).
|
|
*
|
|
* @return string|null 영속 통화 코드(미설정/비로그인 시 null)
|
|
*/
|
|
protected function resolvePersistedUserCurrency(): ?string
|
|
{
|
|
$userId = Auth::id();
|
|
if ($userId === null) {
|
|
return null; // 비로그인/게스트 — 헤더/base 폴백
|
|
}
|
|
|
|
return app(EcommerceUserProfileRepositoryInterface::class)->getPreferredCurrency((int) $userId);
|
|
}
|
|
|
|
/**
|
|
* 결제 통화로 청구 가능한지 검증합니다 (PG/입금 청구 전 가드).
|
|
*
|
|
* 결제 통화(order_currency)가 base 통화와 다를 때, 주문 스냅샷 환율로 환산해
|
|
* PG/입금이 청구할 수 있는 통화·금액인지 확인합니다. 환율이 미설정(0 이하)이거나
|
|
* 환산 결과가 0 이하면 청구가 불가하므로 명확한 예외로 차단합니다. 결제 통화 결정
|
|
* 자체는 정상이나 그 통화로 결제할 수 없는 상황을 주문 생성 전에 막습니다(서버 우회 방지).
|
|
*
|
|
* @param array $currencySnapshot 주문 시점 통화 스냅샷
|
|
* @param float|int $baseFinalAmount 결제예정 base 통화 금액
|
|
*
|
|
* @throws UnsupportedPaymentCurrencyException 결제 통화로 청구할 수 없는 경우
|
|
*/
|
|
protected function assertPaymentCurrencyChargeable(array $currencySnapshot, float|int $baseFinalAmount): void
|
|
{
|
|
$orderCurrency = $currencySnapshot['order_currency'] ?? ($currencySnapshot['base_currency'] ?? 'KRW');
|
|
|
|
try {
|
|
$charge = $this->currencyConversionService->resolveSnapshotPaymentCharge($baseFinalAmount, $currencySnapshot);
|
|
} catch (\InvalidArgumentException $e) {
|
|
// 환율 미설정/0 → 청구 불가
|
|
throw new UnsupportedPaymentCurrencyException(
|
|
$orderCurrency,
|
|
__('sirsoft-ecommerce::exceptions.unsupported_payment_currency', ['currency' => $orderCurrency])
|
|
);
|
|
}
|
|
|
|
if ($charge['minor_unit_amount'] <= 0) {
|
|
// 환산 결과가 0 이하 → PG 최소 청구금액 미만(청구 불가)
|
|
throw new UnsupportedPaymentCurrencyException(
|
|
$orderCurrency,
|
|
__('sirsoft-ecommerce::exceptions.unsupported_payment_currency', ['currency' => $orderCurrency])
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 통화 스냅샷 생성
|
|
*
|
|
* 주문 생성 시점의 통화 정보를 스냅샷으로 저장합니다.
|
|
* 등록된 모든 통화의 환율 정보를 포함합니다.
|
|
*
|
|
* @return array 통화 스냅샷
|
|
*/
|
|
protected function buildCurrencySnapshot(): array
|
|
{
|
|
$baseCurrency = $this->currencyConversionService->getDefaultCurrency();
|
|
|
|
// 결제 통화 = 유저 영속 통화 1순위 → X-Currency 헤더 → base (resolveActiveCurrency SSoT, §A2-b·§A3-b).
|
|
// base 는 모든 금액 테이블의 SSoT(진실값)로 보존되고, 비-base 결제 통화는 base × 스냅샷 환율로 청구된다.
|
|
$currentCurrency = $this->resolveActiveCurrency($baseCurrency);
|
|
|
|
// 등록된 모든 통화의 환율 정보 수집
|
|
$currencies = $this->currencyConversionService->getCurrencySettings();
|
|
$exchangeRates = [];
|
|
$orderExchangeRate = 1.0;
|
|
|
|
foreach ($currencies as $currency) {
|
|
$code = $currency['code'];
|
|
$isDefault = $currency['is_default'] ?? false;
|
|
$rate = $isDefault ? 1.0 : ($currency['exchange_rate'] ?? 0);
|
|
$exchangeRates[$code] = [
|
|
'rate' => $rate,
|
|
'rounding_unit' => $currency['rounding_unit'] ?? ($isDefault ? '1' : '0.01'),
|
|
'rounding_method' => $currency['rounding_method'] ?? 'round',
|
|
'decimal_places' => $currency['decimal_places'] ?? ($isDefault ? 0 : 2),
|
|
// 환산 분모(base_unit)를 통화별로 박제 → 공식 변경 후에도 주문 시점 환산 재현(환차손 0).
|
|
'base_unit' => $this->currencyConversionService->getBaseUnit($code),
|
|
];
|
|
|
|
if ($code === $currentCurrency) {
|
|
$orderExchangeRate = $rate;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'base_currency' => $baseCurrency,
|
|
'order_currency' => $currentCurrency,
|
|
'exchange_rate' => $orderExchangeRate,
|
|
// 스냅샷 변환 분모 = 기본 통화의 base_unit (getSnapshotBaseUnit SSoT).
|
|
'base_unit' => $this->currencyConversionService->getBaseUnit($baseCurrency),
|
|
'exchange_rates' => $exchangeRates,
|
|
'snapshot_at' => Carbon::now()->toIso8601String(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 주문 생성
|
|
*
|
|
* @param TempOrder $tempOrder 임시 주문
|
|
* @param OrderCalculationResult $calculationResult 계산 결과
|
|
* @param OrderStatusEnum $initialStatus 초기 상태
|
|
* @param array $currencySnapshot 통화 스냅샷
|
|
*/
|
|
protected function createOrder(
|
|
TempOrder $tempOrder,
|
|
OrderCalculationResult $calculationResult,
|
|
OrderStatusEnum $initialStatus,
|
|
array $currencySnapshot,
|
|
?string $guestLookupPassword = null,
|
|
array $shippingInfo = []
|
|
): Order {
|
|
$summary = $calculationResult->summary;
|
|
|
|
// 다중 통화 변환
|
|
$mcAmounts = $this->buildOrderMultiCurrency($summary, $currencySnapshot);
|
|
|
|
$orderData = [
|
|
'user_id' => $tempOrder->user_id,
|
|
'order_number' => $this->generateOrderNumber(),
|
|
// 비회원 조회 비밀번호는 해시로만 저장 (평문 미저장, 회원 주문은 null)
|
|
'guest_lookup_password_hash' => $guestLookupPassword !== null
|
|
? Hash::make($guestLookupPassword)
|
|
: null,
|
|
'order_status' => $initialStatus,
|
|
'order_device' => $this->detectDevice(),
|
|
'is_first_order' => $this->isFirstOrder($tempOrder->user_id),
|
|
'ip_address' => request()->ip(),
|
|
'currency' => $currencySnapshot['order_currency'] ?? 'KRW',
|
|
'currency_snapshot' => $currencySnapshot,
|
|
'subtotal_amount' => $summary->subtotal ?? 0,
|
|
'total_discount_amount' => $summary->totalDiscount ?? 0,
|
|
'total_product_coupon_discount_amount' => $summary->productCouponDiscount ?? 0,
|
|
'total_order_coupon_discount_amount' => $summary->orderCouponDiscount ?? 0,
|
|
'total_coupon_discount_amount' => ($summary->productCouponDiscount ?? 0) + ($summary->orderCouponDiscount ?? 0),
|
|
'total_code_discount_amount' => $summary->codeDiscount ?? 0,
|
|
'base_shipping_amount' => $summary->baseShippingTotal ?? 0,
|
|
'extra_shipping_amount' => $summary->extraShippingTotal ?? 0,
|
|
'shipping_discount_amount' => $summary->shippingDiscount ?? 0,
|
|
'total_shipping_amount' => $summary->totalShipping ?? 0,
|
|
'total_amount' => $summary->finalAmount ?? 0,
|
|
'total_tax_amount' => $summary->taxableAmount ?? 0,
|
|
'total_tax_free_amount' => $summary->taxFreeAmount ?? 0,
|
|
// 부가세는 주문 생성 시점에 기록한다. 기존에는 부분취소 재계산에서만 갱신되어
|
|
// 부분취소를 겪은 주문만 값이 정상화되고 나머지는 영구 0 이었다.
|
|
'total_vat_amount' => $this->resolveVatAmount($calculationResult),
|
|
'total_points_used_amount' => $summary->pointsUsed ?? 0,
|
|
'total_deposit_used_amount' => 0,
|
|
'total_paid_amount' => 0,
|
|
// 결제예정금액 = 마일리지/예치금 차감 후 실결제액(finalAmount).
|
|
// PG(KG 이니시스 등) 결제 요청 금액·무통장 입금 안내액의 SSoT 이므로 차감 전 paymentAmount 를 쓰면
|
|
// 마일리지 사용분만큼 과다 청구된다. total_amount 와 동일한 차감 후 금액으로 둔다.
|
|
'total_due_amount' => $summary->finalAmount ?? 0,
|
|
'total_cancelled_amount' => 0,
|
|
'total_refunded_amount' => 0,
|
|
'total_refunded_points_amount' => 0,
|
|
'total_earned_points_amount' => $summary->pointsEarning ?? 0,
|
|
'item_count' => count($calculationResult->items ?? []),
|
|
'total_weight' => 0,
|
|
'total_volume' => 0,
|
|
'ordered_at' => Carbon::now(),
|
|
'promotions_applied_snapshot' => $this->buildPromotionsAppliedSnapshot($calculationResult),
|
|
'shipping_policy_applied_snapshot' => $this->buildShippingPolicyAppliedSnapshot($calculationResult, $shippingInfo),
|
|
// 주문 시점 마일리지 사용 정책 고정 — 이후 관리자가 한도를 바꿔도 이 주문의 판정 근거는 불변
|
|
'mileage_policy_snapshot' => $this->userMileageService->buildUsagePolicySnapshot(
|
|
(int) ($summary->paymentAmount ?? 0),
|
|
(int) ($summary->pointsUsed ?? 0)
|
|
),
|
|
'order_meta' => $this->buildOrderMeta($tempOrder),
|
|
// 다중 통화 필드
|
|
'mc_subtotal_amount' => $mcAmounts['mc_subtotal_amount'],
|
|
'mc_total_discount_amount' => $mcAmounts['mc_total_discount_amount'],
|
|
'mc_total_product_coupon_discount_amount' => $mcAmounts['mc_total_product_coupon_discount_amount'],
|
|
'mc_total_order_coupon_discount_amount' => $mcAmounts['mc_total_order_coupon_discount_amount'],
|
|
'mc_total_coupon_discount_amount' => $mcAmounts['mc_total_coupon_discount_amount'],
|
|
'mc_total_code_discount_amount' => $mcAmounts['mc_total_code_discount_amount'],
|
|
'mc_base_shipping_amount' => $mcAmounts['mc_base_shipping_amount'],
|
|
'mc_extra_shipping_amount' => $mcAmounts['mc_extra_shipping_amount'],
|
|
'mc_shipping_discount_amount' => $mcAmounts['mc_shipping_discount_amount'],
|
|
'mc_total_shipping_amount' => $mcAmounts['mc_total_shipping_amount'],
|
|
'mc_total_points_used_amount' => $mcAmounts['mc_total_points_used_amount'],
|
|
'mc_total_deposit_used_amount' => $mcAmounts['mc_total_deposit_used_amount'],
|
|
'mc_total_tax_amount' => $mcAmounts['mc_total_tax_amount'],
|
|
'mc_total_tax_free_amount' => $mcAmounts['mc_total_tax_free_amount'],
|
|
'mc_total_amount' => $mcAmounts['mc_total_amount'],
|
|
'mc_total_paid_amount' => $mcAmounts['mc_total_paid_amount'],
|
|
];
|
|
|
|
// 훅을 통한 데이터 가공
|
|
$orderData = HookManager::applyFilters('sirsoft-ecommerce.order.filter_create_data', $orderData, $tempOrder);
|
|
|
|
return $this->orderRepository->create($orderData);
|
|
}
|
|
|
|
/**
|
|
* 주문 다중 통화 데이터 생성
|
|
*
|
|
* 등록된 모든 통화로 변환하여 저장합니다.
|
|
*
|
|
* @param object $summary 계산 결과 요약
|
|
* @param array $currencySnapshot 통화 스냅샷
|
|
* @return array 다중 통화 데이터
|
|
*/
|
|
protected function buildOrderMultiCurrency(object $summary, array $currencySnapshot): array
|
|
{
|
|
$amounts = [
|
|
'subtotal' => $summary->subtotal ?? 0,
|
|
'totalDiscount' => $summary->totalDiscount ?? 0,
|
|
'productCouponDiscount' => $summary->productCouponDiscount ?? 0,
|
|
'orderCouponDiscount' => $summary->orderCouponDiscount ?? 0,
|
|
'couponDiscount' => ($summary->productCouponDiscount ?? 0) + ($summary->orderCouponDiscount ?? 0),
|
|
'codeDiscount' => $summary->codeDiscount ?? 0,
|
|
'baseShipping' => $summary->baseShippingTotal ?? 0,
|
|
'extraShipping' => $summary->extraShippingTotal ?? 0,
|
|
'shippingDiscount' => $summary->shippingDiscount ?? 0,
|
|
'totalShipping' => $summary->totalShipping ?? 0,
|
|
'pointsUsed' => $summary->pointsUsed ?? 0,
|
|
'taxableAmount' => $summary->taxableAmount ?? 0,
|
|
'taxFreeAmount' => $summary->taxFreeAmount ?? 0,
|
|
'finalAmount' => $summary->finalAmount ?? 0,
|
|
];
|
|
|
|
$convertAmount = $this->buildAllCurrencyConverter($currencySnapshot);
|
|
|
|
return [
|
|
'mc_subtotal_amount' => $convertAmount($amounts['subtotal']),
|
|
'mc_total_discount_amount' => $convertAmount($amounts['totalDiscount']),
|
|
'mc_total_product_coupon_discount_amount' => $convertAmount($amounts['productCouponDiscount']),
|
|
'mc_total_order_coupon_discount_amount' => $convertAmount($amounts['orderCouponDiscount']),
|
|
'mc_total_coupon_discount_amount' => $convertAmount($amounts['couponDiscount']),
|
|
'mc_total_code_discount_amount' => $convertAmount($amounts['codeDiscount']),
|
|
'mc_base_shipping_amount' => $convertAmount($amounts['baseShipping']),
|
|
'mc_extra_shipping_amount' => $convertAmount($amounts['extraShipping']),
|
|
'mc_shipping_discount_amount' => $convertAmount($amounts['shippingDiscount']),
|
|
'mc_total_shipping_amount' => $convertAmount($amounts['totalShipping']),
|
|
'mc_total_points_used_amount' => $convertAmount($amounts['pointsUsed']),
|
|
'mc_total_deposit_used_amount' => $convertAmount(0),
|
|
'mc_total_tax_amount' => $convertAmount($amounts['taxableAmount']),
|
|
'mc_total_tax_free_amount' => $convertAmount($amounts['taxFreeAmount']),
|
|
'mc_total_amount' => $convertAmount($amounts['finalAmount']),
|
|
'mc_total_paid_amount' => $convertAmount(0),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 주문 옵션 생성
|
|
*
|
|
* @param Order $order 주문
|
|
* @param TempOrder $tempOrder 임시 주문
|
|
* @param OrderCalculationResult $calculationResult 계산 결과
|
|
* @param array $currencySnapshot 통화 스냅샷
|
|
* @return void
|
|
*/
|
|
/**
|
|
* 주문 옵션 생성
|
|
*
|
|
* @param Order $order 주문
|
|
* @param TempOrder $tempOrder 임시 주문
|
|
* @param OrderCalculationResult $calculationResult 계산 결과
|
|
* @param array $currencySnapshot 통화 스냅샷
|
|
* @return array<int, OrderOption> productOptionId => OrderOption 매핑
|
|
*/
|
|
protected function createOrderOptions(
|
|
Order $order,
|
|
TempOrder $tempOrder,
|
|
OrderCalculationResult $calculationResult,
|
|
array $currencySnapshot
|
|
): array {
|
|
$createdOptions = [];
|
|
|
|
foreach ($calculationResult->items as $item) {
|
|
// 상품/옵션 스냅샷 생성
|
|
$product = $this->productRepository->find($item->productId);
|
|
$productOption = $this->productOptionRepository->findById($item->productOptionId);
|
|
|
|
$productSnapshot = $product ? $product->toSnapshotArray() : [];
|
|
$optionSnapshot = $productOption ? $productOption->toSnapshotArray() : [];
|
|
|
|
// 다중 통화 변환
|
|
$mcAmounts = $this->buildOptionMultiCurrency($item, $currencySnapshot);
|
|
|
|
// product_name: JSON 형식 (다국어 지원)
|
|
$productName = $product ? $product->name : ($item->productName ?? '');
|
|
$productNameJson = is_array($productName) ? $productName : ['ko' => $productName, 'en' => $productName];
|
|
|
|
// 적용 프로모션 스냅샷 (toArray()로 snake_case 변환 필수)
|
|
$appliedPromotions = $item->appliedPromotions?->toArray() ?? [];
|
|
|
|
$orderOption = $order->options()->create([
|
|
'product_id' => $item->productId,
|
|
'product_option_id' => $item->productOptionId,
|
|
'option_status' => OrderStatusEnum::PENDING_ORDER,
|
|
'sku' => $productOption->sku ?? null,
|
|
'product_name' => $productNameJson,
|
|
'product_option_name' => $productOption?->option_name ?? [],
|
|
'option_name' => $productOption?->option_name ?? [],
|
|
'option_value' => $this->buildOptionValueSummary($productOption),
|
|
'quantity' => $item->quantity,
|
|
'unit_weight' => $productOption->weight ?? 0,
|
|
'unit_volume' => $productOption->volume ?? 0,
|
|
'subtotal_weight' => ($productOption->weight ?? 0) * $item->quantity,
|
|
'subtotal_volume' => ($productOption->volume ?? 0) * $item->quantity,
|
|
'unit_price' => $item->unitPrice,
|
|
'additional_options_total' => $item->additionalOptionsTotal ?? 0,
|
|
'subtotal_price' => $item->subtotal,
|
|
'subtotal_discount_amount' => $item->getTotalDiscount(),
|
|
'product_coupon_discount_amount' => $item->productCouponDiscountAmount ?? 0,
|
|
'order_coupon_discount_amount' => $item->orderCouponDiscountShare ?? 0,
|
|
'coupon_discount_amount' => $item->productCouponDiscountAmount ?? 0,
|
|
'code_discount_amount' => $item->codeDiscountAmount ?? 0,
|
|
'subtotal_points_used_amount' => $item->pointsUsedShare ?? 0,
|
|
'subtotal_deposit_used_amount' => $item->depositUsedShare ?? 0,
|
|
'subtotal_paid_amount' => $item->finalAmount ?? 0,
|
|
'subtotal_tax_amount' => $item->taxableAmount ?? 0,
|
|
'subtotal_tax_free_amount' => $item->taxFreeAmount ?? 0,
|
|
'subtotal_earned_points_amount' => $item->pointsEarning ?? 0,
|
|
'product_snapshot' => $productSnapshot,
|
|
'option_snapshot' => $optionSnapshot,
|
|
'additional_options_snapshot' => $item->additionalOptionsSnapshot ?? [],
|
|
'promotions_applied_snapshot' => $appliedPromotions,
|
|
// 다중 통화 필드
|
|
'mc_unit_price' => $mcAmounts['mc_unit_price'],
|
|
'mc_additional_options_total' => $mcAmounts['mc_additional_options_total'] ?? null,
|
|
'mc_subtotal_price' => $mcAmounts['mc_subtotal_price'],
|
|
'mc_product_coupon_discount_amount' => $mcAmounts['mc_product_coupon_discount_amount'],
|
|
'mc_order_coupon_discount_amount' => $mcAmounts['mc_order_coupon_discount_amount'],
|
|
'mc_coupon_discount_amount' => $mcAmounts['mc_coupon_discount_amount'],
|
|
'mc_code_discount_amount' => $mcAmounts['mc_code_discount_amount'],
|
|
'mc_subtotal_points_used_amount' => $mcAmounts['mc_subtotal_points_used_amount'],
|
|
'mc_subtotal_earned_points_amount' => $mcAmounts['mc_subtotal_earned_points_amount'],
|
|
'mc_subtotal_deposit_used_amount' => $mcAmounts['mc_subtotal_deposit_used_amount'],
|
|
'mc_subtotal_tax_amount' => $mcAmounts['mc_subtotal_tax_amount'],
|
|
'mc_subtotal_tax_free_amount' => $mcAmounts['mc_subtotal_tax_free_amount'],
|
|
'mc_final_amount' => $mcAmounts['mc_final_amount'],
|
|
]);
|
|
|
|
// productOptionId → OrderOption 매핑 저장
|
|
$createdOptions[$item->productOptionId] = $orderOption;
|
|
}
|
|
|
|
return $createdOptions;
|
|
}
|
|
|
|
/**
|
|
* 옵션값 요약 문자열 생성 (다국어)
|
|
*
|
|
* ProductOption의 option_values를 로케일별 요약 문자열로 변환합니다.
|
|
* 예: {"ko": "색상: 빨강, 사이즈: L", "en": "Color: Red, Size: L"}
|
|
*
|
|
* @param ProductOption|null $productOption 상품 옵션
|
|
* @return array 다국어 요약 문자열 배열
|
|
*/
|
|
protected function buildOptionValueSummary(?ProductOption $productOption): array
|
|
{
|
|
if (! $productOption || empty($productOption->option_values)) {
|
|
return [];
|
|
}
|
|
|
|
$values = $productOption->option_values;
|
|
|
|
// 새 구조: [{"key": {"ko": "색상"}, "value": {"ko": "빨강"}}]
|
|
if (isset($values[0]['key'])) {
|
|
$locales = config('app.supported_locales', ['ko', 'en']);
|
|
$result = [];
|
|
|
|
foreach ($locales as $locale) {
|
|
$parts = [];
|
|
foreach ($values as $item) {
|
|
$key = $item['key'] ?? [];
|
|
$value = $item['value'] ?? [];
|
|
|
|
$localizedKey = is_array($key) ? ($key[$locale] ?? $key[config('app.fallback_locale', 'ko')] ?? array_values($key)[0] ?? '') : $key;
|
|
$localizedValue = is_array($value) ? ($value[$locale] ?? $value[config('app.fallback_locale', 'ko')] ?? array_values($value)[0] ?? '') : $value;
|
|
|
|
if ($localizedKey !== '' && $localizedValue !== '') {
|
|
$parts[] = $localizedKey.': '.$localizedValue;
|
|
}
|
|
}
|
|
$result[$locale] = implode(', ', $parts);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
// 기존 구조: {"색상": "빨강"} (하위 호환성) - ko로만 반환
|
|
$parts = [];
|
|
foreach ($values as $key => $value) {
|
|
$parts[] = $key.': '.$value;
|
|
}
|
|
|
|
return ['ko' => implode(', ', $parts)];
|
|
}
|
|
|
|
/**
|
|
* 주문 옵션 다중 통화 데이터 생성
|
|
*
|
|
* 등록된 모든 통화로 변환하여 저장합니다.
|
|
*
|
|
* @param object $item 계산 결과 아이템
|
|
* @param array $currencySnapshot 통화 스냅샷
|
|
* @return array 다중 통화 데이터
|
|
*/
|
|
protected function buildOptionMultiCurrency(object $item, array $currencySnapshot): array
|
|
{
|
|
$convertAmount = $this->buildAllCurrencyConverter($currencySnapshot);
|
|
|
|
return [
|
|
'mc_unit_price' => $convertAmount($item->unitPrice ?? 0),
|
|
'mc_additional_options_total' => $convertAmount($item->additionalOptionsTotal ?? 0),
|
|
'mc_subtotal_price' => $convertAmount($item->subtotal ?? 0),
|
|
'mc_product_coupon_discount_amount' => $convertAmount($item->productCouponDiscountAmount ?? 0),
|
|
'mc_order_coupon_discount_amount' => $convertAmount($item->orderCouponDiscountShare ?? 0),
|
|
'mc_coupon_discount_amount' => $convertAmount($item->productCouponDiscountAmount ?? 0),
|
|
'mc_code_discount_amount' => $convertAmount($item->codeDiscountAmount ?? 0),
|
|
'mc_subtotal_points_used_amount' => $convertAmount($item->pointsUsedShare ?? 0),
|
|
'mc_subtotal_earned_points_amount' => $convertAmount($item->pointsEarning ?? 0),
|
|
'mc_subtotal_deposit_used_amount' => $convertAmount(0),
|
|
'mc_subtotal_tax_amount' => $convertAmount($item->taxableAmount ?? 0),
|
|
'mc_subtotal_tax_free_amount' => $convertAmount($item->taxFreeAmount ?? 0),
|
|
'mc_final_amount' => $convertAmount($item->finalAmount ?? 0),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 주문 주소 생성
|
|
*
|
|
* @param Order $order 주문
|
|
* @param array $ordererInfo 주문자 정보
|
|
* @param array $shippingInfo 배송지 정보
|
|
* @param string|null $shippingMemo 배송 메모
|
|
*/
|
|
protected function createOrderAddresses(
|
|
Order $order,
|
|
array $ordererInfo,
|
|
array $shippingInfo,
|
|
?string $shippingMemo
|
|
): void {
|
|
$countryCode = strtoupper((string) ($shippingInfo['country_code'] ?? 'KR'));
|
|
$isDomestic = $countryCode === '' || $countryCode === 'KR';
|
|
|
|
// 배송지 주소 생성 (국내/해외 필드 모두 보존 — B2 해외 주소 유실 수정)
|
|
$order->addresses()->create([
|
|
'address_type' => 'shipping',
|
|
'orderer_name' => $ordererInfo['name'] ?? '',
|
|
'orderer_phone' => $ordererInfo['phone'] ?? '',
|
|
'orderer_email' => $ordererInfo['email'] ?? '',
|
|
// 주문 시점 화면 언어 스냅샷 — 비회원 알림(이메일) 발송 언어 결정용
|
|
'orderer_locale' => app()->getLocale(),
|
|
'recipient_name' => $shippingInfo['recipient_name'] ?? '',
|
|
'recipient_phone' => $shippingInfo['recipient_phone'] ?? $shippingInfo['phone'] ?? '',
|
|
'recipient_country_code' => $countryCode !== '' ? $countryCode : 'KR',
|
|
// 국내(KR) 필드 — 다음우편번호/도로명 주소 (NOT NULL 컬럼이라 해외 주문 시 빈 문자열)
|
|
'zipcode' => $shippingInfo['zipcode'] ?? $shippingInfo['zonecode'] ?? '',
|
|
'address' => $shippingInfo['address'] ?? '',
|
|
'address_detail' => $shippingInfo['address_detail'] ?? $shippingInfo['detail_address'] ?? '',
|
|
// 해외 배송 필드 — 체크아웃 제출 키와 1:1 매핑 (CreateOrderRequest 검증 완료)
|
|
'address_line_1' => $shippingInfo['address_line_1'] ?? null,
|
|
'address_line_2' => $shippingInfo['address_line_2'] ?? null,
|
|
'intl_city' => $shippingInfo['intl_city'] ?? null,
|
|
'intl_state' => $shippingInfo['intl_state'] ?? null,
|
|
'intl_postal_code' => $shippingInfo['intl_postal_code'] ?? null,
|
|
'address_type_code' => $shippingInfo['address_type_code'] ?? null,
|
|
'delivery_memo' => $shippingMemo,
|
|
'delivery_memo_label' => DeliveryMemoPresetEnum::resolveLabel($shippingMemo),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 주문 결제 정보 생성
|
|
*
|
|
* @param Order $order 주문
|
|
* @param string $paymentMethod 결제 수단
|
|
* @param string|null $depositorName 입금자명
|
|
* @param array|null $dbankInfo 무통장 수동입금 정보 (dbank 결제 시)
|
|
* @param OrderCalculationResult $calculationResult 계산 결과
|
|
* @param array $currencySnapshot 통화 스냅샷
|
|
* @param array $ordererInfo 주문자 정보 (name/email/phone — 결제 구매자 정보로 기록)
|
|
*/
|
|
protected function createOrderPayment(
|
|
Order $order,
|
|
string $paymentMethod,
|
|
?string $depositorName,
|
|
?array $dbankInfo,
|
|
OrderCalculationResult $calculationResult,
|
|
array $currencySnapshot,
|
|
array $ordererInfo = []
|
|
): void {
|
|
$paymentAmount = $calculationResult->summary->paymentAmount ?? $calculationResult->summary->finalAmount ?? 0;
|
|
// 결제액 0원(전액 비현금 충당: 마일리지/예치금 등) → 결제 레코드도 즉시 PAID, PG/현금 결제액 0.
|
|
$isZeroPayable = (int) ($calculationResult->summary->finalAmount ?? 0) <= 0;
|
|
|
|
// 다중 통화 변환
|
|
$mcPaidAmount = $this->buildMultiCurrencyAmount(0, $currencySnapshot);
|
|
$mcCancelledAmount = $this->buildMultiCurrencyAmount(0, $currencySnapshot);
|
|
|
|
// 유입 요청 컨텍스트 — 결제 디바이스/User-Agent 산출.
|
|
// 무통장(dbank)·0원 주문은 PG 콜백(completePayment)을 거치지 않으므로 생성 시점에 기록해야
|
|
// 결제 디바이스/UA 가 영구 NULL 로 남지 않는다. PG 결제 수단은 콜백이 더 정확한 값으로 덮어쓴다.
|
|
$userAgent = request()->userAgent() ?? '';
|
|
|
|
$vatAmount = $this->resolveVatAmount($calculationResult);
|
|
|
|
$paymentData = [
|
|
'payment_method' => $paymentMethod,
|
|
'payment_status' => $isZeroPayable ? PaymentStatusEnum::PAID : PaymentStatusEnum::READY,
|
|
'pg_provider' => $this->determinePgProvider($paymentMethod),
|
|
'merchant_order_id' => $order->order_number.'_'.time(),
|
|
'paid_amount_local' => 0,
|
|
'paid_amount_base' => $isZeroPayable ? 0 : $paymentAmount,
|
|
'vat_amount' => $vatAmount,
|
|
'currency' => $currencySnapshot['order_currency'] ?? 'KRW',
|
|
'currency_snapshot' => $currencySnapshot,
|
|
'mc_paid_amount' => $mcPaidAmount,
|
|
'mc_cancelled_amount' => $mcCancelledAmount,
|
|
// 결제 요청 시작 시점 = 주문 생성 시점 (전 결제수단 공통).
|
|
'payment_started_at' => Carbon::now(),
|
|
// 구매자 정보 (주문자 정보를 결제 레코드에도 기록 — 환불/대사 시 결제 단위 식별).
|
|
'buyer_name' => $ordererInfo['name'] ?? null,
|
|
'buyer_email' => $ordererInfo['email'] ?? null,
|
|
'buyer_phone' => $ordererInfo['phone'] ?? null,
|
|
// 결제명 (상품명 요약 — 주문 항목 기반).
|
|
'payment_name' => $this->buildPaymentName($calculationResult),
|
|
// 결제 디바이스/UA (생성 시 기록, PG 콜백이 있으면 더 정확히 덮어씀).
|
|
'payment_device' => DeviceDetector::detectFromUserAgent($userAgent),
|
|
'user_agent' => $userAgent !== '' ? $userAgent : null,
|
|
];
|
|
|
|
// 결제액 0원 주문은 생성 시점에 결제완료이므로 결제 시각 기록
|
|
if ($isZeroPayable) {
|
|
$paymentData['paid_at'] = Carbon::now();
|
|
}
|
|
|
|
// 무통장입금 (PG 가상계좌) 정보
|
|
if ($paymentMethod === PaymentMethodEnum::VBANK->value) {
|
|
$paymentData['vbank_holder'] = $depositorName;
|
|
// 입금기한 단일 SSoT: auto_cancel_days (결제수단 무관)
|
|
$paymentData['vbank_due_at'] = Carbon::now()->addDays($this->resolveAutoCancelDays());
|
|
}
|
|
|
|
// 무통장입금 (수동 입금) 정보
|
|
if ($paymentMethod === PaymentMethodEnum::DBANK->value && $dbankInfo) {
|
|
$bankCode = $dbankInfo['bank_code'] ?? null;
|
|
|
|
// bank_name이 없으면 설정에서 은행코드 기반으로 조회
|
|
$bankName = $dbankInfo['bank_name'] ?? null;
|
|
if (! $bankName && $bankCode) {
|
|
$orderSettings = module_setting('sirsoft-ecommerce', 'order_settings');
|
|
$banks = collect($orderSettings['banks'] ?? []);
|
|
$bank = $banks->firstWhere('code', $bankCode);
|
|
$bankName = $bank ? ($bank['name'][app()->getLocale()] ?? $bank['name']['ko'] ?? $bankCode) : $bankCode;
|
|
}
|
|
|
|
$paymentData['dbank_code'] = $bankCode;
|
|
$paymentData['dbank_name'] = $bankName;
|
|
$paymentData['dbank_account'] = $dbankInfo['account_number'] ?? null;
|
|
$paymentData['dbank_holder'] = $dbankInfo['account_holder'] ?? null;
|
|
$paymentData['depositor_name'] = $depositorName ?? $dbankInfo['depositor_name'] ?? null;
|
|
// 입금기한 단일 SSoT: auto_cancel_days (VBANK 와 동일 기준).
|
|
// 클라이언트가 보낸 due_days 는 무시한다 — 기한은 서버 정책이며, 이를 받아들이면
|
|
// 미입금 자동취소 스케줄러와 안내 기한이 어긋난다.
|
|
$paymentData['deposit_due_at'] = Carbon::now()->addDays($this->resolveAutoCancelDays());
|
|
}
|
|
|
|
$order->payment()->create($paymentData);
|
|
}
|
|
|
|
/**
|
|
* 입금기한(일)을 정수로 해석합니다.
|
|
*
|
|
* 설정값은 관리자 화면(HTML number 입력)을 거치면서 문자열로 저장될 수 있고,
|
|
* Carbon 의 날짜 연산은 strict 타입 경계라 문자열을 받으면 TypeError 를 던진다.
|
|
* 저장 타입과 무관하게 정수를 보장하고, 허용 범위를 벗어난 값은 안전한 값으로 보정한다.
|
|
*
|
|
* @return int 입금기한 일수 (허용 범위로 클램프된 값)
|
|
*/
|
|
private function resolveAutoCancelDays(): int
|
|
{
|
|
$fallback = self::AUTO_CANCEL_DAYS_DEFAULT;
|
|
|
|
$raw = module_setting('sirsoft-ecommerce', 'order_settings.auto_cancel_days', $fallback);
|
|
$days = is_numeric($raw) ? (int) $raw : $fallback;
|
|
|
|
// 0 이하(빈 문자열/미설정/오염값)는 즉시 만료를 뜻하므로 기본값으로 되돌린다.
|
|
if ($days <= 0) {
|
|
$days = $fallback;
|
|
}
|
|
|
|
// 허용 범위는 검증 규칙(StoreEcommerceSettingsRequest)과 같은 limits 설정을 SSoT 로 쓴다.
|
|
// 여기서 다시 좁히는 것이 아니라, 검증을 거치지 않고 영속된 값(구버전 데이터·직접 편집)만
|
|
// 안전 범위로 되돌린다 — 주문 생성 자체를 실패시키지 않기 위함.
|
|
$min = max(
|
|
self::AUTO_CANCEL_DAYS_MIN_FALLBACK,
|
|
(int) config('sirsoft-ecommerce.limits.auto_cancel_days_min', self::AUTO_CANCEL_DAYS_MIN_FALLBACK)
|
|
);
|
|
$max = max(
|
|
$min,
|
|
(int) config('sirsoft-ecommerce.limits.auto_cancel_days_max', self::AUTO_CANCEL_DAYS_MAX_FALLBACK)
|
|
);
|
|
|
|
return max($min, min($max, $days));
|
|
}
|
|
|
|
/**
|
|
* 결제명(상품명 요약)을 생성합니다.
|
|
*
|
|
* 주문 항목의 상품명을 기반으로 "첫 상품명 외 N건" 형태로 요약합니다.
|
|
* PG 결제창·영수증·결제 내역에 표시되는 대표 명칭입니다.
|
|
*
|
|
* @param OrderCalculationResult $calculationResult 계산 결과
|
|
* @return string|null 결제명 (항목이 없으면 null)
|
|
*/
|
|
protected function buildPaymentName(OrderCalculationResult $calculationResult): ?string
|
|
{
|
|
$names = [];
|
|
foreach ($calculationResult->items as $item) {
|
|
$name = $item->productName ?? null;
|
|
if ($name !== null && $name !== '') {
|
|
$names[] = $name;
|
|
}
|
|
}
|
|
|
|
if (empty($names)) {
|
|
return null;
|
|
}
|
|
|
|
$first = $names[0];
|
|
$rest = count($names) - 1;
|
|
|
|
return $rest > 0
|
|
? __('sirsoft-ecommerce::messages.orders.payment_name_summary', ['name' => $first, 'count' => $rest])
|
|
: $first;
|
|
}
|
|
|
|
/**
|
|
* PG 제공자 결정
|
|
*
|
|
* @param string $paymentMethod 결제 수단
|
|
* @return string PG 제공자
|
|
*/
|
|
public function determinePgProvider(string $paymentMethod): string
|
|
{
|
|
$enum = PaymentMethodEnum::tryFrom($paymentMethod);
|
|
|
|
// PG 불필요 결제수단
|
|
if ($enum && ! $enum->needsPgProvider()) {
|
|
return $paymentMethod === PaymentMethodEnum::DBANK->value ? 'manual' : 'internal';
|
|
}
|
|
|
|
// 설정에서 조회: 개별 오버라이드 > 기본 PG
|
|
$methodConfig = $this->settingsService->getPaymentMethodConfig($paymentMethod);
|
|
$provider = $methodConfig['pg_provider']
|
|
?? $this->settingsService->getSetting('order_settings.default_pg_provider');
|
|
|
|
return $provider ?? 'none';
|
|
}
|
|
|
|
/**
|
|
* 단일 금액 다중 통화 변환
|
|
*
|
|
* 등록된 모든 통화로 변환하여 반환합니다.
|
|
*
|
|
* @param float $amount 금액
|
|
* @param array $currencySnapshot 통화 스냅샷
|
|
* @return array 다중 통화 데이터
|
|
*/
|
|
protected function buildMultiCurrencyAmount(float $amount, array $currencySnapshot): array
|
|
{
|
|
$convertAmount = $this->buildAllCurrencyConverter($currencySnapshot);
|
|
|
|
return $convertAmount($amount);
|
|
}
|
|
|
|
/**
|
|
* 모든 등록 통화로 변환하는 클로저 생성
|
|
*
|
|
* CurrencyConversionService의 convertToMultiCurrency를 활용하여
|
|
* 등록된 모든 통화 금액을 {통화코드: 금액} 형식으로 반환합니다.
|
|
*
|
|
* @param array $currencySnapshot 통화 스냅샷
|
|
* @return \Closure(float): array 변환 클로저
|
|
*/
|
|
protected function buildAllCurrencyConverter(array $currencySnapshot): \Closure
|
|
{
|
|
return function (float $amount) use ($currencySnapshot): array {
|
|
return $this->currencyConversionService->convertToMultiCurrencyWithSnapshot(
|
|
(int) $amount,
|
|
$currencySnapshot
|
|
);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 주문 배송 정보 생성
|
|
*
|
|
* @param Order $order 주문
|
|
* @param TempOrder $tempOrder 임시 주문
|
|
* @param OrderCalculationResult $calculationResult 계산 결과
|
|
* @param array $currencySnapshot 통화 스냅샷
|
|
* @param array $createdOptions productOptionId => OrderOption 매핑
|
|
*/
|
|
protected function createOrderShippings(
|
|
Order $order,
|
|
TempOrder $tempOrder,
|
|
OrderCalculationResult $calculationResult,
|
|
array $currencySnapshot,
|
|
array $createdOptions = []
|
|
): void {
|
|
foreach ($calculationResult->items as $item) {
|
|
// 해당 아이템의 OrderOption 찾기
|
|
$orderOption = $createdOptions[$item->productOptionId] ?? null;
|
|
|
|
if ($orderOption === null) {
|
|
continue; // 주문 옵션이 없으면 배송 정보 생성 불가
|
|
}
|
|
|
|
// 배송 정책이 있는 경우에만 배송 정보 생성
|
|
$shippingPolicy = $item->appliedShippingPolicy;
|
|
$totalShippingFee = $shippingPolicy->totalShippingAmount ?? 0;
|
|
|
|
// 배송 유형 결정 (국내/해외)
|
|
$shippingType = $this->determineShippingType($shippingPolicy, $tempOrder);
|
|
|
|
$order->shippings()->create([
|
|
'order_option_id' => $orderOption->id,
|
|
'shipping_policy_id' => $shippingPolicy->policyId ?? null,
|
|
'shipping_status' => ShippingStatusEnum::PENDING->value,
|
|
'shipping_type' => $shippingType,
|
|
'base_shipping_amount' => $shippingPolicy->shippingAmount ?? 0,
|
|
'extra_shipping_amount' => $shippingPolicy->extraShippingAmount ?? 0,
|
|
'total_shipping_amount' => $totalShippingFee,
|
|
'shipping_discount_amount' => $shippingPolicy->shippingDiscountAmount ?? 0,
|
|
'is_remote_area' => ($shippingPolicy->extraShippingAmount ?? 0) > 0,
|
|
'delivery_policy_snapshot' => $shippingPolicy->policySnapshot ?? null,
|
|
'currency_snapshot' => $currencySnapshot,
|
|
'mc_base_shipping_amount' => $this->buildMultiCurrencyAmount($shippingPolicy->shippingAmount ?? 0, $currencySnapshot),
|
|
'mc_extra_shipping_amount' => $this->buildMultiCurrencyAmount($shippingPolicy->extraShippingAmount ?? 0, $currencySnapshot),
|
|
'mc_total_shipping_amount' => $this->buildMultiCurrencyAmount($totalShippingFee, $currencySnapshot),
|
|
'mc_shipping_discount_amount' => $this->buildMultiCurrencyAmount($shippingPolicy->shippingDiscountAmount ?? 0, $currencySnapshot),
|
|
'mc_return_shipping_amount' => $this->buildMultiCurrencyAmount(0, $currencySnapshot),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 배송 유형 결정
|
|
*
|
|
* @param object|null $shippingPolicy 배송 정책
|
|
* @param TempOrder $tempOrder 임시 주문
|
|
*/
|
|
protected function determineShippingType(?object $shippingPolicy, TempOrder $tempOrder): string
|
|
{
|
|
// 배송 정책에 type이 있으면 사용
|
|
if ($shippingPolicy && ! empty($shippingPolicy->type)) {
|
|
$validCodes = $this->shippingTypeRepository->getActiveCodes();
|
|
if (in_array($shippingPolicy->type, $validCodes)) {
|
|
return $shippingPolicy->type;
|
|
}
|
|
}
|
|
|
|
// 배송지 국가로 판단 (임시 주문에서)
|
|
$shippingCountry = $tempOrder->shipping_address['country_code'] ?? 'KR';
|
|
|
|
if ($shippingCountry !== 'KR') {
|
|
return $this->shippingTypeRepository->getFirstActiveCodeByCategory('international') ?? 'international_standard';
|
|
}
|
|
|
|
return $this->shippingTypeRepository->getFirstActiveCodeByCategory('domestic') ?? 'parcel';
|
|
}
|
|
|
|
/**
|
|
* 주문번호 생성
|
|
*
|
|
* SequenceService를 사용하여 타임스탬프 기반 주문번호를 생성합니다.
|
|
* 형식: 20260208-1435226549 (Ymd-His + 밀리초3자리 + 랜덤1자리)
|
|
* DB 트랜잭션 + FOR UPDATE 락으로 동시성 제어됩니다.
|
|
*
|
|
* @return string 주문번호
|
|
*/
|
|
protected function generateOrderNumber(): string
|
|
{
|
|
return $this->sequenceService->generateCode(SequenceType::ORDER);
|
|
}
|
|
|
|
/**
|
|
* 디바이스 타입 감지
|
|
*/
|
|
protected function detectDevice(): DeviceTypeEnum
|
|
{
|
|
$userAgent = request()->userAgent();
|
|
|
|
if (preg_match('/mobile|android|iphone|ipad/i', $userAgent)) {
|
|
return DeviceTypeEnum::MOBILE;
|
|
}
|
|
|
|
return DeviceTypeEnum::PC;
|
|
}
|
|
|
|
/**
|
|
* 주문 상품의 구매 대상 제한을 최종 검증합니다.
|
|
*
|
|
* 체크아웃 단계에서 사전 안내하더라도, 프론트 우회 가능성을 막기 위해
|
|
* 주문 생성 직전 서버에서 다시 검사합니다. 회원/비회원 공통 적용하며,
|
|
* 비회원은 코어 guest 역할로 판정합니다.
|
|
*
|
|
* @param TempOrder $tempOrder 임시 주문
|
|
*
|
|
* @throws CartUnavailableException 구매 권한이 없는 상품이 있는 경우
|
|
*/
|
|
protected function validatePurchaseEligibility(TempOrder $tempOrder): void
|
|
{
|
|
$items = $tempOrder->items ?? [];
|
|
$productIds = array_values(array_unique(array_filter(array_column($items, 'product_id'))));
|
|
|
|
if (empty($productIds)) {
|
|
return;
|
|
}
|
|
|
|
$products = $this->productRepository->findByIdsKeyed($productIds);
|
|
$userRoleIds = $this->purchaseEligibilityService->resolveRoleIds(Auth::user());
|
|
|
|
$restricted = $this->purchaseEligibilityService->filterRestrictedProducts($products, $userRoleIds);
|
|
|
|
if (empty($restricted)) {
|
|
return;
|
|
}
|
|
|
|
// 차단 상품을 CartUnavailableException 의 unavailable_items 형식으로 구성
|
|
$unavailableItems = [];
|
|
foreach ($restricted as $product) {
|
|
$unavailableItems[] = [
|
|
'product_id' => $product->id,
|
|
'name' => $product->getLocalizedName(),
|
|
'reason' => 'restricted',
|
|
];
|
|
}
|
|
|
|
throw CartUnavailableException::fromItems($unavailableItems);
|
|
}
|
|
|
|
/**
|
|
* 주문 상품이 선택된 배송국가로 배송 가능한지 최종 검증합니다. (D1)
|
|
*
|
|
* 각 상품의 배송정책(ShippingPolicy)에 해당 국가 설정(getCountrySetting)이 없으면
|
|
* 그 국가로 배송 불가로 판정합니다. 미지원 상품이 1개라도 있으면 주문 전체를 차단합니다
|
|
* (혼재 시 전체 차단, 부분주문 없음). 카트/체크아웃 표시(layer 1/2)를 우회한 직접 호출도
|
|
* 막는 최종 방어선입니다.
|
|
*
|
|
* @param TempOrder $tempOrder 임시 주문
|
|
* @param string|null $countryCode 제출된 배송국가 코드 (없으면 KR)
|
|
*
|
|
* @throws CartUnavailableException 미지원 배송국가 상품이 1개 이상이면
|
|
*/
|
|
protected function validateShippingCountrySupported(TempOrder $tempOrder, ?string $countryCode): void
|
|
{
|
|
$country = strtoupper((string) ($countryCode ?? 'KR'));
|
|
if ($country === '') {
|
|
$country = 'KR';
|
|
}
|
|
|
|
$items = $tempOrder->items ?? [];
|
|
$productIds = array_values(array_unique(array_filter(array_column($items, 'product_id'))));
|
|
|
|
if (empty($productIds)) {
|
|
return;
|
|
}
|
|
|
|
$products = $this->productRepository->findByIdsKeyed($productIds);
|
|
|
|
$unavailableItems = [];
|
|
foreach ($products as $product) {
|
|
// 상품에 정책이 없으면(shipping_policy_id=null) 기본 배송정책으로 폴백 판정한다.
|
|
// 적용 가능한 정책이 전혀 없을 때만 국내(KR) 기본 배송으로 간주.
|
|
if (! $this->shippingPolicyResolver->isShippableToCountry($product, $country)) {
|
|
$unavailableItems[] = [
|
|
'product_id' => $product->id,
|
|
'name' => $product->getLocalizedName(),
|
|
'reason' => 'country_not_shippable',
|
|
'country_code' => $country,
|
|
];
|
|
}
|
|
}
|
|
|
|
if (! empty($unavailableItems)) {
|
|
throw CartUnavailableException::fromItems($unavailableItems);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 첫 주문 여부 확인
|
|
*
|
|
* 비회원 주문(user_id = null)은 회원 식별이 불가능해 첫 주문 혜택 판단 대상이
|
|
* 아니므로 항상 false 로 처리합니다.
|
|
*
|
|
* @param int|null $userId 회원 ID (비회원은 null)
|
|
*/
|
|
protected function isFirstOrder(?int $userId): bool
|
|
{
|
|
if ($userId === null) {
|
|
return false;
|
|
}
|
|
|
|
return ! $this->orderRepository->hasOrderByUser($userId);
|
|
}
|
|
|
|
/**
|
|
* 주문/결제 레코드에 기록할 부가세를 결정합니다.
|
|
*
|
|
* 계산 단계에서 옵션별 세율로 산출한 합계를 그대로 사용합니다 — 여기서 다시 단일 세율로
|
|
* 계산하면 세율이 섞인 주문에서 주문과 결제 값이 어긋납니다.
|
|
*
|
|
* 다만 부가세 합산 도입 이전에 만들어진 임시주문이 도입 이후에 결제되면 합계가 비어 있어
|
|
* 부가세가 0 으로 굳습니다. 이 경우에 한해 과세표준에 기본 세율을 적용해 채웁니다
|
|
* (기존 주문 백필과 동일한 기준).
|
|
*
|
|
* @param OrderCalculationResult $calculationResult 주문 계산 결과
|
|
* @return int 부가세 금액
|
|
*/
|
|
protected function resolveVatAmount(OrderCalculationResult $calculationResult): int
|
|
{
|
|
$summary = $calculationResult->summary;
|
|
$vatAmount = (int) ($summary->vatAmount ?? 0);
|
|
|
|
if ($vatAmount > 0) {
|
|
return $vatAmount;
|
|
}
|
|
|
|
$taxableAmount = (int) ($summary->taxableAmount ?? 0);
|
|
|
|
return $taxableAmount > 0
|
|
? VatCalculator::fromTaxableAmount($taxableAmount, VatCalculator::DEFAULT_RATE)
|
|
: 0;
|
|
}
|
|
|
|
/**
|
|
* 마일리지 사용 정책을 주문 확정 직전에 재검증합니다.
|
|
*
|
|
* 임시주문 단계 검증 이후에 관리자가 한도 설정을 바꾸거나 임시주문이 조작된 경우를
|
|
* 차단하는 2차 방어선입니다. 판정 기준 금액은 마일리지 차감 전 결제금액입니다.
|
|
*
|
|
* @param TempOrder $tempOrder 임시 주문
|
|
* @param OrderCalculationResult $calculationResult 재계산 결과
|
|
*
|
|
* @throws MileageValidationException 정책 위반 시
|
|
*/
|
|
protected function validateMileageUsagePolicy(TempOrder $tempOrder, OrderCalculationResult $calculationResult): void
|
|
{
|
|
$userId = $tempOrder->user_id;
|
|
$usePoints = (int) $tempOrder->getUsedPoints();
|
|
|
|
// 비회원은 마일리지 사용 불가 (임시주문 단계에서 이미 0)
|
|
if ($userId === null || $usePoints <= 0) {
|
|
return;
|
|
}
|
|
|
|
$this->userMileageService->validateUsage(
|
|
$userId,
|
|
$usePoints,
|
|
(int) ($calculationResult->summary->paymentAmount ?? 0)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 임시 주문에서 계산 입력 DTO를 구성합니다.
|
|
*
|
|
* TempOrderService::getTempOrderWithCalculation()의 패턴을 따릅니다.
|
|
*
|
|
* @param TempOrder $tempOrder 임시 주문
|
|
*/
|
|
protected function buildCalculationInputFromTempOrder(TempOrder $tempOrder): CalculationInput
|
|
{
|
|
// 저장된 아이템으로 CalculationItem 배열 생성
|
|
$items = $tempOrder->items ?? [];
|
|
$calculationItems = array_map(
|
|
fn (array $item) => CalculationItem::fromArray($item),
|
|
$items
|
|
);
|
|
|
|
// 저장된 배송 주소
|
|
$shippingAddress = null;
|
|
if ($tempOrder->getShippingAddress() !== null) {
|
|
$shippingAddress = ShippingAddress::fromArray($tempOrder->getShippingAddress());
|
|
}
|
|
|
|
// 저장된 프로모션 정보에서 쿠폰 ID 배열 구성
|
|
$promotions = $tempOrder->getPromotions();
|
|
$couponIssueIds = array_filter([
|
|
$promotions['order_coupon_issue_id'] ?? null,
|
|
$promotions['shipping_coupon_issue_id'] ?? null,
|
|
]);
|
|
|
|
return new CalculationInput(
|
|
items: $calculationItems,
|
|
couponIssueIds: $couponIssueIds,
|
|
itemCoupons: $promotions['item_coupons'] ?? [],
|
|
usePoints: $tempOrder->getUsedPoints(),
|
|
shippingAddress: $shippingAddress,
|
|
userId: $tempOrder->user_id,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 적용된 프로모션 스냅샷을 구성합니다.
|
|
*
|
|
* @param OrderCalculationResult $result 계산 결과
|
|
*/
|
|
protected function buildPromotionsAppliedSnapshot(OrderCalculationResult $result): array
|
|
{
|
|
$snapshot = $result->promotions->toArray();
|
|
|
|
// 플러그인이 스냅샷에 자체 할인 데이터를 추가할 수 있는 훅
|
|
// 예: 유입할인 플러그인이 { "referral_discount": { "amount": 3000 } } 추가
|
|
$snapshot = HookManager::applyFilters(
|
|
'sirsoft-ecommerce.calculation.filter_promotions_snapshot',
|
|
$snapshot,
|
|
$result
|
|
);
|
|
|
|
return $snapshot;
|
|
}
|
|
|
|
/**
|
|
* 적용된 배송정책 스냅샷을 구성합니다.
|
|
*
|
|
* @param OrderCalculationResult $result 계산 결과
|
|
*/
|
|
protected function buildShippingPolicyAppliedSnapshot(OrderCalculationResult $result, array $shippingInfo = []): array
|
|
{
|
|
$items = [];
|
|
foreach ($result->items as $item) {
|
|
if ($item->appliedShippingPolicy) {
|
|
$items[] = [
|
|
'product_option_id' => $item->productOptionId,
|
|
'policy' => $item->appliedShippingPolicy->toArray(),
|
|
];
|
|
}
|
|
}
|
|
|
|
// 배송지(국가/우편번호) 스냅샷 보존 (B5 — 환불/취소 재계산 시 도서산간/국가별 정책 판단 복원).
|
|
//
|
|
// 항목 목록과 배송지 메타는 **별도 키**로 분리한다. 종전에는 `$policies[]` 리스트에
|
|
// `$policies['address']` 를 덧붙여 한 배열에 섞었고, 그 결과 PHP 배열이
|
|
// non-sequential 이 되어 `json_encode` 가 객체로 직렬화했다. 서버는 `is_int($key)` 로
|
|
// 버텼지만 배열을 전제하는 프론트에서 `.find is not a function` 이 나며 마이페이지·
|
|
// 비회원 주문 상세의 배송정책 블록이 조용히 사라졌다.
|
|
return ShippingPolicySnapshot::make($items, [
|
|
'country_code' => strtoupper((string) ($shippingInfo['country_code'] ?? 'KR')),
|
|
'zipcode' => $shippingInfo['zipcode'] ?? $shippingInfo['intl_postal_code'] ?? null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 주문 메타데이터를 구성합니다.
|
|
*
|
|
* @param TempOrder $tempOrder 임시 주문
|
|
*/
|
|
protected function buildOrderMeta(TempOrder $tempOrder): array
|
|
{
|
|
$cartItems = array_filter(
|
|
array_map(function ($item) {
|
|
return isset($item['cart_id']) ? [
|
|
'cart_id' => $item['cart_id'],
|
|
'quantity' => $item['quantity'] ?? 0,
|
|
] : null;
|
|
}, $tempOrder->items ?? [])
|
|
);
|
|
|
|
return [
|
|
'temp_order_id' => $tempOrder->id,
|
|
'calculation_input' => $tempOrder->calculation_input,
|
|
'cart_items' => array_values($cartItems),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 주문된 장바구니 아이템 처리 (삭제 또는 수량 차감)
|
|
*
|
|
* 장바구니 수량 > 주문 수량: 수량 차감
|
|
* 장바구니 수량 <= 주문 수량: 삭제
|
|
*
|
|
* @param Order $order 주문
|
|
*/
|
|
protected function clearOrderedCartItems(Order $order): void
|
|
{
|
|
$cartItems = $order->order_meta['cart_items'] ?? [];
|
|
|
|
if (empty($cartItems)) {
|
|
return;
|
|
}
|
|
|
|
$cartIds = array_column($cartItems, 'cart_id');
|
|
$orderedQtyMap = [];
|
|
foreach ($cartItems as $item) {
|
|
$orderedQtyMap[$item['cart_id']] = $item['quantity'];
|
|
}
|
|
|
|
$existingCarts = $this->cartRepository->findByIds($cartIds);
|
|
|
|
$deleteIds = [];
|
|
foreach ($existingCarts as $cart) {
|
|
$orderedQty = $orderedQtyMap[$cart->id] ?? 0;
|
|
$remainingQty = $cart->quantity - $orderedQty;
|
|
|
|
if ($remainingQty > 0) {
|
|
$this->cartRepository->update($cart, ['quantity' => $remainingQty]);
|
|
} else {
|
|
$deleteIds[] = $cart->id;
|
|
}
|
|
}
|
|
|
|
if (! empty($deleteIds)) {
|
|
$this->cartRepository->deleteByIds($deleteIds);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 결제 완료 후 주문 상태 변경
|
|
*
|
|
* PG 결제의 경우 $pgAmount를 전달하면 금액 검증을 수행합니다.
|
|
* 기존 무통장입금 등 $pgAmount 없이 호출하면 검증 없이 상태 전환됩니다.
|
|
*
|
|
* @param Order $order 주문
|
|
* @param array $paymentData 결제 데이터
|
|
* @param int|null $pgAmount PG사에서 전달받은 결제금액 (null이면 금액 검증 생략)
|
|
* @return Order 결제 완료된 주문 (fresh)
|
|
*
|
|
* @throws PaymentAmountMismatchException 금액 불일치 시
|
|
*/
|
|
public function completePayment(Order $order, array $paymentData = [], ?int $pgAmount = null): Order
|
|
{
|
|
// 이미 결제완료된 주문(예: 전액 마일리지 결제로 생성 시점에 PAID 확정)은 재처리하지 않는다.
|
|
// 결제완료 훅 이중 발화(중복 적립 등)를 방어한다.
|
|
if ($order->order_status === OrderStatusEnum::PAYMENT_COMPLETE) {
|
|
return $order->fresh();
|
|
}
|
|
|
|
// PG 금액이 전달된 경우 금액 검증 (컴포넌트 합산 + PG 금액 일치)
|
|
if ($pgAmount !== null) {
|
|
$this->validatePaymentAmount($order, $pgAmount);
|
|
}
|
|
|
|
HookManager::doAction('sirsoft-ecommerce.order.before_payment_complete', $order, $paymentData);
|
|
|
|
DB::transaction(function () use ($order, $paymentData) {
|
|
$paidAmount = $order->total_due_amount;
|
|
$currencySnapshot = $order->currency_snapshot ?? $this->buildCurrencySnapshot();
|
|
|
|
// 다중 통화 결제 금액 계산
|
|
$mcTotalPaidAmount = $this->buildMultiCurrencyAmount($paidAmount, $currencySnapshot);
|
|
|
|
// 결제 통화(order_currency) 실청구 금액. base≠결제 통화일 때 paid_amount_local 은
|
|
// base 값이 아니라 스냅샷 환율로 환산된 결제 통화 금액이어야 한다(PG 실청구·환불 단위 정합).
|
|
$orderCurrency = $currencySnapshot['order_currency'] ?? ($currencySnapshot['base_currency'] ?? 'KRW');
|
|
$paidAmountLocal = $mcTotalPaidAmount[$orderCurrency] ?? $paidAmount;
|
|
|
|
// 주문 상태 변경
|
|
$order->update([
|
|
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
|
'paid_at' => Carbon::now(),
|
|
'total_paid_amount' => $paidAmount,
|
|
'total_due_amount' => 0,
|
|
'mc_total_paid_amount' => $mcTotalPaidAmount,
|
|
]);
|
|
|
|
// 주문 옵션 상태도 결제완료로 동기화 (취소/클레임 옵션 제외)
|
|
// 옵션은 생성 시 PENDING_ORDER 로 시작하므로 결제완료 시 함께 전이해야
|
|
// 주문상세 화면에 "주문대기"로 갇히지 않는다.
|
|
$this->syncOptionStatuses($order, OrderStatusEnum::PAYMENT_COMPLETE);
|
|
|
|
// 결제 정보 업데이트 (PG 응답 필드 확장)
|
|
$order->payment()->update(array_filter([
|
|
'payment_status' => PaymentStatusEnum::PAID,
|
|
'paid_at' => Carbon::now(),
|
|
// paid_amount_local = 결제 통화(order_currency) 환산 실청구액, paid_amount_base = base 진실값.
|
|
'paid_amount_local' => $paidAmountLocal,
|
|
'paid_amount_base' => $paidAmount,
|
|
'mc_paid_amount' => $mcTotalPaidAmount,
|
|
// 기본 PG 정보
|
|
'transaction_id' => $paymentData['transaction_id'] ?? null,
|
|
// 카드 결제 정보
|
|
'card_approval_number' => $paymentData['card_approval_number'] ?? null,
|
|
'card_number_masked' => $paymentData['card_number_masked'] ?? null,
|
|
'card_name' => $paymentData['card_name'] ?? null,
|
|
'card_installment_months' => $paymentData['card_installment_months'] ?? null,
|
|
'is_interest_free' => $paymentData['is_interest_free'] ?? null,
|
|
// 간편결제 PG 정보
|
|
'embedded_pg_provider' => $paymentData['embedded_pg_provider'] ?? null,
|
|
// 영수증/메타
|
|
'receipt_url' => $paymentData['receipt_url'] ?? null,
|
|
'payment_meta' => $paymentData['payment_meta'] ?? null,
|
|
'payment_device' => $paymentData['payment_device'] ?? null,
|
|
], fn ($v) => $v !== null));
|
|
|
|
// 마일리지 사용 차감 (payment_complete 타이밍 — 결제가 실제 완료된 시점에 차감).
|
|
// 생성 시 차감(order_placed/0원 주문)으로 이미 is_mileage_deducted=true 인 주문은 재차감하지 않는다.
|
|
$usedPoints = (int) $order->total_points_used_amount;
|
|
if ($usedPoints > 0 && ! $order->is_mileage_deducted) {
|
|
$this->deductMileageForOrder($order, $usedPoints);
|
|
}
|
|
|
|
// 마일리지 적립
|
|
if ($order->total_earned_points_amount > 0) {
|
|
HookManager::doAction('sirsoft-ecommerce.mileage.earn', $order->total_earned_points_amount, $order);
|
|
}
|
|
|
|
// 재고 차감 + 장바구니 처리 (payment_complete 타이밍: 트랜잭션 내부에서 실행)
|
|
$paymentMethodId = $order->payment->paymentMethodId();
|
|
$timing = $this->settingsService->getStockDeductionTiming($paymentMethodId);
|
|
if ($timing === 'payment_complete') {
|
|
$this->stockService->deductStock($order->load('options'));
|
|
$this->clearOrderedCartItems($order);
|
|
}
|
|
});
|
|
|
|
HookManager::doAction('sirsoft-ecommerce.order.after_payment_complete', $order);
|
|
|
|
// 주문 확인 알림 훅 (결제 완료 = 주문 확인 시점)
|
|
HookManager::doAction('sirsoft-ecommerce.order.after_confirm', $order);
|
|
|
|
// 관리자 신규주문 알림 — 카드(PG) 주문은 주문 생성 시점이 아니라 결제완료 시점에 발송한다.
|
|
// (무통장/비-PG/0원 주문은 각자 다른 시점에 이미 발화하므로 여기서 발화하지 않는다)
|
|
HookManager::doAction('sirsoft-ecommerce.order.after_admin_notify', $order);
|
|
|
|
// 임시주문 정리 (PG 결제 완료 시점에 삭제, 이미 삭제된 경우 no-op)
|
|
if ($order->user_id) {
|
|
// 회원: user_id 기준 삭제
|
|
$this->tempOrderService->deleteTempOrder($order->user_id, null);
|
|
} else {
|
|
// 비회원: cart_key 가 없으므로 order_meta 에 보관된 temp_order_id 로 삭제
|
|
$tempOrderId = $order->order_meta['temp_order_id'] ?? null;
|
|
if ($tempOrderId !== null) {
|
|
$this->tempOrderService->deleteTempOrderById((int) $tempOrderId);
|
|
}
|
|
}
|
|
|
|
// PG 결제 완료 시 배송지 자동 저장
|
|
$orderMeta = $order->order_meta ?? [];
|
|
if ($order->user_id && ($orderMeta['save_shipping_address'] ?? false)) {
|
|
try {
|
|
$shippingData = $orderMeta['shipping_info_for_save'] ?? [];
|
|
$name = $this->userAddressService->generateUniqueName(
|
|
$order->user_id,
|
|
__('sirsoft-ecommerce::messages.address.auto_saved_label')
|
|
);
|
|
// 국가별 명시 매핑 (B4 — 폴백 혼재 해외 필드 누락/오저장 방지)
|
|
$this->userAddressService->createAddress(
|
|
$this->userAddressService->mapShippingInfoToAddressData($order->user_id, $name, $shippingData)
|
|
);
|
|
|
|
// 메타에서 배송지 저장 플래그 제거
|
|
$order->update([
|
|
'order_meta' => array_diff_key($orderMeta, array_flip(['save_shipping_address', 'shipping_info_for_save'])),
|
|
]);
|
|
} catch (\Exception $e) {
|
|
Log::warning('Auto save shipping address failed on payment complete', [
|
|
'user_id' => $order->user_id,
|
|
'order_id' => $order->id,
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
return $order->fresh();
|
|
}
|
|
|
|
/**
|
|
* 무통장 수동 입금확인 처리
|
|
*
|
|
* "입금 기록"과 "주문 결제완료 전이"를 분리해 처리한다:
|
|
* - 입금 기록(항상): 결제 레코드(payment)를 입금완료(PAID)로 정합화 — paid_at·입금액·입금자명.
|
|
* 입금액은 결제예정금액과 정확히 일치해야 한다(불일치 시 422).
|
|
* - 결제완료 전이(선택): $markOrderComplete=true 이고 주문이 아직 미결제일 때만, 결제완료 SSoT
|
|
* (completePayment)에 위임해 option_status 동기화·마일리지·재고·결제완료 알림을 일괄 처리한다.
|
|
*
|
|
* 분리 배경: "이미 결제완료된 주문에 추가 금액(교환/반품 배송비 등)을 무통장으로 받아 수동 기록"
|
|
* 하는 케이스에서는 마일리지/재고/알림 연쇄를 다시 태우면 안 된다. 또한 관리자가 주문 상태를
|
|
* 먼저 결제완료로 바꿔 둔(order_status=payment_complete / payment=미입금) 불일치 주문도
|
|
* 결제완료 전이 없이 payment 만 정합화해야 한다.
|
|
*
|
|
* 관리자 수동 입금확인(Admin\OrderController::confirmDeposit)과 추후 무통장 입금 자동처리
|
|
* 플러그인(은행 입금통보 webhook)이 동일하게 이 공개 메서드를 호출한다.
|
|
*
|
|
* @param Order $order 주문 모델 (무통장)
|
|
* @param float $amount 입금 금액 (결제예정금액과 정확 일치해야 함)
|
|
* @param string|null $depositorName 입금자명
|
|
* @param bool $markOrderComplete true 면 주문을 결제완료로 전이(마일리지/재고/알림 포함). 기본 true(기존 동작 호환)
|
|
* @return Order 입금확인 처리된 주문 (fresh)
|
|
*
|
|
* @throws PaymentAmountMismatchException 입금액이 결제예정금액과 불일치 시
|
|
*/
|
|
public function confirmManualDeposit(
|
|
Order $order,
|
|
float $amount,
|
|
?string $depositorName = null,
|
|
bool $markOrderComplete = true
|
|
): Order {
|
|
// 훅: 무통장 입금 확인 전 (관리자 민감 작업 — IDV 정책 가드 지점).
|
|
// EnforceIdentityPolicyListener 가 'sirsoft-ecommerce.payment.confirm_deposit' 정책이
|
|
// 활성이고 grace 만료 시 IdentityVerificationRequiredException(428) 을 throw.
|
|
HookManager::doAction('sirsoft-ecommerce.payment.before_confirm_deposit', $order, $amount, $depositorName);
|
|
|
|
$order->loadMissing('payment');
|
|
|
|
// 결제 레코드가 이미 입금완료(PAID)면 멱등 처리 — 추가 입금 기록은 본 메서드 범위 밖.
|
|
if ($order->payment?->payment_status === PaymentStatusEnum::PAID) {
|
|
return $order->fresh();
|
|
}
|
|
|
|
// 입금자명 기록 (선택 입력) — 결제완료 전이/입금 기록 양 경로 공통.
|
|
// completePayment 는 입금자명을 모르므로 위임 전에 먼저 기록한다.
|
|
if ($depositorName !== null && $depositorName !== '') {
|
|
$order->payment?->update(['depositor_name' => $depositorName]);
|
|
}
|
|
|
|
// 결제완료 전이 동반: 주문이 아직 미결제일 때만 결제완료 SSoT(completePayment)에 위임.
|
|
// completePayment 가 payment 도 PAID 로 정합화하므로 입금 기록을 중복하지 않는다.
|
|
// order_status 가 이미 결제완료인 경우(불일치/추가입금)는 아래 입금 기록만 수행한다.
|
|
if ($markOrderComplete && $order->order_status !== OrderStatusEnum::PAYMENT_COMPLETE) {
|
|
return $this->completePayment($order, [], (int) round($amount));
|
|
}
|
|
|
|
// 입금 기록만 수행: 결제완료 전이(마일리지/재고/알림) 없이 payment 만 PAID 로 정합화.
|
|
// 금액 검증은 동일하게 적용(결제예정금액 정확 일치). 입금자명은 위에서 이미 기록함.
|
|
$this->recordDepositPayment($order, $amount);
|
|
|
|
return $order->fresh();
|
|
}
|
|
|
|
/**
|
|
* 결제 레코드(payment)만 입금완료(PAID)로 정합화합니다 — 주문 상태 전이 없이.
|
|
*
|
|
* 입금액이 결제예정금액과 정확히 일치하는지 검증한 뒤(불일치 시 422), 결제 레코드의
|
|
* payment_status·paid_at·입금액을 갱신한다. 마일리지/재고/알림 등 결제완료 부수효과는
|
|
* 발생시키지 않는다(그건 completePayment 의 책임). 입금자명은 호출부에서 기록한다.
|
|
*
|
|
* @param Order $order 주문
|
|
* @param float $amount 입금 금액
|
|
*
|
|
* @throws PaymentAmountMismatchException 입금액이 결제예정금액과 불일치 시
|
|
*/
|
|
protected function recordDepositPayment(Order $order, float $amount): void
|
|
{
|
|
$payment = $order->payment;
|
|
if ($payment === null) {
|
|
return;
|
|
}
|
|
|
|
// 결제예정금액 정확 일치 검증 (completePayment 와 동일 기준).
|
|
// $amount 는 결제 통화(order_currency) 청구액(validatePayment 2단계 기준)이므로
|
|
// paid_amount_local 에 그대로 들어가고, paid_amount_base 는 base 진실값으로 동결한다.
|
|
$this->validatePaymentAmount($order, (int) round($amount));
|
|
|
|
$payment->update([
|
|
'payment_status' => PaymentStatusEnum::PAID,
|
|
'paid_at' => Carbon::now(),
|
|
'paid_amount_local' => (int) round($amount),
|
|
'paid_amount_base' => (float) $order->total_due_amount,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* PG 결제 금액 검증 (2단계)
|
|
*
|
|
* 검증 기준 = 결제예정금액(total_due_amount). 모든 PG 가 결제창에 청구하는 금액(buildPgPaymentData →
|
|
* total_due_amount)·각 PG 플러그인의 사전 가드(signature/SignData)·본 최종 승인 검증이 동일 컬럼을
|
|
* 기준으로 동작해야 마일리지/예치금 차감 후 실청구액 기준으로 일관 검증된다.
|
|
*
|
|
* 1단계: 주문 개별 금액 컴포넌트 재합산(마일리지 차감 포함) → total_due_amount 일치 확인 (DB 변조 감지)
|
|
* 2단계: 주문 total_due_amount → PG 콜백 실청구액 일치 확인
|
|
*
|
|
* @param Order $order 주문
|
|
* @param int $pgAmount PG사에서 전달받은 결제금액
|
|
*
|
|
* @throws PaymentAmountMismatchException 금액 불일치 시
|
|
*/
|
|
protected function validatePaymentAmount(Order $order, int $pgAmount): void
|
|
{
|
|
// 1단계: 컴포넌트 재합산 검증 (DB 변조 감지). 마일리지 사용분(total_points_used_amount)까지
|
|
// 차감하므로 재합산 결과 = 차감 후 실청구액 = total_due_amount 여야 한다.
|
|
$calculatedTotal = round(
|
|
$order->subtotal_amount
|
|
- $order->total_product_coupon_discount_amount
|
|
- $order->total_order_coupon_discount_amount
|
|
- $order->total_code_discount_amount
|
|
+ $order->base_shipping_amount
|
|
+ $order->extra_shipping_amount
|
|
- $order->shipping_discount_amount
|
|
- $order->total_points_used_amount,
|
|
2
|
|
);
|
|
$dueAmount = round($order->total_due_amount, 2);
|
|
|
|
if ($calculatedTotal !== $dueAmount) {
|
|
throw new PaymentAmountMismatchException(
|
|
$dueAmount,
|
|
$calculatedTotal,
|
|
[
|
|
'stage' => 'component_verification',
|
|
'order_id' => $order->id,
|
|
'order_number' => $order->order_number,
|
|
]
|
|
);
|
|
}
|
|
|
|
// 2단계: PG 금액 일치 검증.
|
|
// 비교 기준 = 결제 통화(order_currency) 청구액. PG 결제창에 청구한 금액
|
|
// (buildPgPaymentData → resolveOrderPaymentChargeAmount)·각 PG 콜백 승인액과 동일 SSoT 다.
|
|
// base(total_due_amount) 를 직접 비교하면 base≠결제 통화(예: base JPY, 결제 KRW)일 때
|
|
// PG 청구액(환산 KRW)과 단위가 어긋나 "결제금액 불일치" 회귀가 발생한다.
|
|
$expectedChargeAmount = $this->currencyConversionService->resolveOrderPaymentChargeAmount($order);
|
|
|
|
if ($expectedChargeAmount !== $pgAmount) {
|
|
throw new PaymentAmountMismatchException(
|
|
$pgAmount,
|
|
$expectedChargeAmount,
|
|
[
|
|
'stage' => 'pg_amount_verification',
|
|
'order_id' => $order->id,
|
|
'order_number' => $order->order_number,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PG 결제 실패 처리
|
|
*
|
|
* 결제 대기 상태의 주문을 취소 상태로 전환하고
|
|
* 결제 실패 정보를 메타데이터에 기록합니다.
|
|
*
|
|
* @param Order $order 주문
|
|
* @param string $errorCode 에러 코드
|
|
* @param string $errorMessage 에러 메시지
|
|
* @return Order 결제 실패 처리된 주문 (fresh)
|
|
*/
|
|
public function failPayment(Order $order, string $errorCode, string $errorMessage): Order
|
|
{
|
|
// 결제 전 상태인 경우에만 처리
|
|
if (! $order->order_status->isBeforePayment()) {
|
|
return $order;
|
|
}
|
|
|
|
// 결제가 이미 성공(payment_status=PAID)한 주문은 실패 처리하지 않는다.
|
|
// 카드(PG) 주문은 승인 직전까지 order_status=PENDING_ORDER 이므로, PG 승인 콜백
|
|
// (completePayment, payment 를 먼저 PAID 로 갱신)과 결제창 닫힘 보고(failPayment)가
|
|
// 경쟁하면 order_status 만 보는 위 가드를 통과해 옵션을 CANCELLED 로 덮을 수 있다.
|
|
// 이후 order_status 는 completePayment 가 결제완료로 덮지만 옵션은 syncExcludedValues
|
|
// 보호로 되살아나지 못해 "주문=결제완료 / 옵션=취소" 모순이 고착된다.
|
|
// payment_status 를 함께 확인해 결제 성공 주문의 실패 처리를 원천 차단한다.
|
|
if ($order->payment?->isPaid()) {
|
|
return $order;
|
|
}
|
|
|
|
DB::transaction(function () use ($order, $errorCode, $errorMessage) {
|
|
// 마일리지 선차감분 복원 (order_placed 타이밍 등으로 결제 전 차감된 경우).
|
|
// 정식 주문취소가 아닌 결제 실패 경로는 OrderCancellationService 를 거치지 않으므로
|
|
// 여기서 직접 복원하지 않으면 차감된 마일리지가 영구 손실된다.
|
|
// is_mileage_deducted 플래그 + 결제 전 상태 가드(위)가 이중 복원을 방지한다.
|
|
if ($order->is_mileage_deducted && $order->user_id !== null) {
|
|
$usedPoints = (float) $order->total_points_used_amount;
|
|
if ($usedPoints > 0) {
|
|
$currency = $this->userMileageService->baseCurrencyForOrder($order);
|
|
$this->userMileageService->restoreForFailedPayment($order->user_id, $order->id, $usedPoints, $currency);
|
|
}
|
|
$order->is_mileage_deducted = false;
|
|
}
|
|
|
|
$order->update([
|
|
'order_status' => OrderStatusEnum::CANCELLED,
|
|
'is_mileage_deducted' => false,
|
|
'order_meta' => array_merge($order->order_meta ?? [], [
|
|
'payment_failure_code' => $errorCode,
|
|
'payment_failure_message' => $errorMessage,
|
|
'payment_failed_at' => Carbon::now()->toIso8601String(),
|
|
]),
|
|
]);
|
|
|
|
// 주문 옵션 상태도 취소로 동기화 (이미 취소/부분취소된 옵션은 syncExcludedValues 로 보존).
|
|
// 이 동기화가 없으면 order_status=CANCELLED ↔ option_status=PENDING_ORDER 불일치가 잔존한다 (U19①).
|
|
$this->syncOptionStatuses($order, OrderStatusEnum::CANCELLED);
|
|
});
|
|
|
|
HookManager::doAction('sirsoft-ecommerce.order.payment_failed', $order->fresh(), $errorCode, $errorMessage);
|
|
|
|
return $order->fresh();
|
|
}
|
|
|
|
/**
|
|
* 주문의 사용 마일리지를 차감하고 차감 플래그를 기록합니다 (트랜잭션 내부 호출).
|
|
*
|
|
* mileage.use 훅을 발화해 마일리지 리스너가 FIFO 차감을 수행하게 하고,
|
|
* is_mileage_deducted 플래그를 true 로 기록한다. 이 플래그는 복원(취소/결제실패) 시
|
|
* 실제 차감이 일어난 주문만 복원하도록 가드하는 기준이 된다.
|
|
*
|
|
* @param Order $order 주문
|
|
* @param int $usedPoints 사용 마일리지
|
|
*/
|
|
protected function deductMileageForOrder(Order $order, int $usedPoints): void
|
|
{
|
|
HookManager::doAction('sirsoft-ecommerce.mileage.use', $usedPoints, $order);
|
|
|
|
$order->update(['is_mileage_deducted' => true]);
|
|
}
|
|
|
|
/**
|
|
* 주문 옵션 상태를 주문 상태와 동기화합니다.
|
|
*
|
|
* 결제완료 등으로 주문 전체 상태가 전이될 때, 각 옵션의 option_status 도
|
|
* 같은 상태로 따라가야 화면(주문상세·마이페이지)에 결제완료로 표시된다.
|
|
* 옵션 생성 시 PENDING_ORDER(주문대기)로 시작하므로, 이 동기화가 없으면
|
|
* 주문은 결제완료인데 옵션만 "주문대기"에 갇힌다.
|
|
*
|
|
* 취소/환불·클레임 등 별도 라이프사이클 옵션은 OrderStatusEnum SSoT 로 제외한다.
|
|
*
|
|
* @param Order $order 대상 주문
|
|
* @param OrderStatusEnum $newStatus 동기화할 상태
|
|
*/
|
|
protected function syncOptionStatuses(Order $order, OrderStatusEnum $newStatus): void
|
|
{
|
|
$order->options()
|
|
->whereNotIn('option_status', OrderStatusEnum::syncExcludedValues())
|
|
->update([
|
|
'option_status' => $newStatus->value,
|
|
'updated_at' => Carbon::now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 결제 취소 이력 기록
|
|
*
|
|
* 유저가 PG 결제창을 닫았을 때 order_payments의 상태와 이력을 업데이트합니다.
|
|
* - payment_status → 'cancelled'
|
|
* - cancel_history에 취소 이력 추가 (PG사 응답 코드/메시지 포함)
|
|
* 주문 상태(order_status)는 변경하지 않습니다 (pending_order 유지).
|
|
*
|
|
* @param Order $order 주문
|
|
* @param string|null $cancelCode PG사 취소 코드 (예: USER_CANCEL)
|
|
* @param string|null $cancelMessage PG사 취소 메시지
|
|
* @return Order 갱신된 주문 (fresh)
|
|
*/
|
|
public function recordPaymentCancellation(Order $order, ?string $cancelCode = null, ?string $cancelMessage = null): Order
|
|
{
|
|
$payment = $order->payment;
|
|
|
|
if (! $payment) {
|
|
return $order;
|
|
}
|
|
|
|
$cancelHistory = $payment->cancel_history ?? [];
|
|
$cancelHistory[] = [
|
|
'cancel_code' => $cancelCode ?? 'UNKNOWN',
|
|
'cancel_message' => $cancelMessage,
|
|
'cancelled_at' => Carbon::now()->toIso8601String(),
|
|
];
|
|
|
|
$payment->update([
|
|
'payment_status' => PaymentStatusEnum::CANCELLED->value,
|
|
'cancelled_at' => Carbon::now(),
|
|
'cancel_history' => $cancelHistory,
|
|
]);
|
|
|
|
return $order->fresh();
|
|
}
|
|
|
|
/**
|
|
* 주문번호로 주문 조회
|
|
*
|
|
* @param string $orderNumber 주문번호
|
|
* @return Order|null 주문 (없으면 null)
|
|
*/
|
|
public function findByOrderNumber(string $orderNumber): ?Order
|
|
{
|
|
return $this->orderRepository->findByOrderNumber($orderNumber);
|
|
}
|
|
|
|
/**
|
|
* 주문 취소
|
|
*
|
|
* OrderCancellationService로 위임합니다.
|
|
*
|
|
* @param Order $order 주문
|
|
* @param string|null $reason 취소 사유
|
|
* @param int|null $cancelledBy 취소 요청자 ID
|
|
* @return Order 취소 처리된 주문
|
|
*
|
|
* @throws \Exception
|
|
*/
|
|
public function cancelOrder(Order $order, ?string $reason = null, ?int $cancelledBy = null): Order
|
|
{
|
|
$cancellationService = app(OrderCancellationService::class);
|
|
$result = $cancellationService->cancelOrder($order, $reason, null, $cancelledBy);
|
|
|
|
return $result->order;
|
|
}
|
|
}
|