Files
Gnuboard7/modules/_bundled/sirsoft-ecommerce/src/Services/UserMileageService.php
T
HeuJung 60cccbf62d feat(ecommerce): 마일리지 적립 절사 기준 신설 및 주문 시점 스냅샷
적립 포인트 절사가 코드에 1점 버림으로 고정돼 있어 운영자가 조정할 수 없었다.
언어/통화 설정의 환율 절사를 따라가는 방안을 먼저 검토했으나, 그쪽은 외화 환산
분기에서만 적용되어 기본 통화 상점에는 따라갈 값 자체가 없다. 적립은 기본 통화
원장에 정수로 확정 기록되는 값이라 성격이 다르므로 마일리지 통화별 규칙에 신설했다.

- earn_rounding_unit(1/10/100) + earn_rounding_method(floor/round/ceil)
- 기본값은 종전 하드코딩과 정확히 같은 금액을 산출 — 기존 쇼핑몰 적립액 무변화
- 주문 계산·옵션 분할·부분취소 안분 전 경로에 동일 기준 적용
- 주문 시점 값을 스냅샷으로 보관 — 이후 기준을 바꿔도 과거 주문이 소급 변경되지 않는다
- 설정 UI·검증·다국어(ko/en/ja)·API 문서 동반, 기존 사이트는 업그레이드 스텝이 명시 기입

함께 처리: 마일리지 내역 목록 테스트가 첫 컬럼 field 를 `no` 로 단언해 실패하던 것을
현재 레이아웃 값(`number`)에 맞춰 정정했다. 이 브랜치와 무관한 stale 케이스다.
2026-08-04 09:56:00 +09:00

1164 lines
47 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Modules\Sirsoft\Ecommerce\Services;
use App\ActivityLog\Traits\ResolvesActivityLogType;
use App\Extension\HookManager;
use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Ecommerce\DTO\MileageAdminDeductDto;
use Modules\Sirsoft\Ecommerce\DTO\MileageAdminEarnDto;
use Modules\Sirsoft\Ecommerce\Enums\MileageTransactionTypeEnum;
use Modules\Sirsoft\Ecommerce\Exceptions\MileageValidationException;
use Modules\Sirsoft\Ecommerce\Models\MileageTransaction;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageBalanceRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageTransactionRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
/**
* 사용자 마일리지 서비스 (원장 SSoT + 표시 캐시)
*
* 잔액 변경 경로는 모두 동일 트랜잭션 내 마지막 단계에서 캐시(MileageBalanceRepository)를 재계산합니다.
* 금전 검증(차감/검증)은 캐시가 아닌 원장 FOR UPDATE 재검증을 사용합니다.
*/
class UserMileageService
{
use ResolvesActivityLogType;
/**
* @param MileageTransactionRepositoryInterface $ledger 원장 Repository
* @param MileageBalanceRepositoryInterface $cache 잔액 캐시 Repository
* @param EcommerceSettingsService $settings 환경설정 서비스
*/
public function __construct(
private MileageTransactionRepositoryInterface $ledger,
private MileageBalanceRepositoryInterface $cache,
private EcommerceSettingsService $settings,
) {}
/**
* 관리자 내역 목록을 조회합니다 (필터/페이지네이션).
*
* @param array $filters 필터 조건
* @param int $perPage 페이지당 개수
* @return LengthAwarePaginator 페이지네이터
*/
public function paginateAdminHistory(array $filters, int $perPage = 20): LengthAwarePaginator
{
return $this->ledger->paginateWithFilters($filters, $perPage);
}
/**
* 회원 uuid 를 정수 user_id 로 해석합니다 (관리자 수동 액션용).
*
* 코어 UserResource 가 정수 id 를 노출하지 않으므로 관리자 화면은 회원을 uuid 로 전달합니다.
*
* @param string $uuid 회원 uuid
* @return int|null 정수 user_id (미존재 시 null)
*/
public function resolveUserIdByUuid(string $uuid): ?int
{
return $this->ledger->resolveUserIdByUuid($uuid);
}
/**
* 통화 필터 옵션 목록을 반환합니다 (설정 currency_rules 기준).
*
* 관리자 내역 통화 필터의 선택지 = 설정에 등록된 통화. 미설정 시 기본통화 단독.
*
* @return array<int, string> 통화 코드 배열
*/
public function getFilterCurrencies(): array
{
$rules = (array) $this->settings->getSetting('mileage.currency_rules', []);
$currencies = [];
foreach ($rules as $rule) {
$code = $rule['currency_code'] ?? null;
if (is_string($code) && $code !== '') {
$currencies[$code] = true;
}
}
$codes = array_keys($currencies);
return $codes !== [] ? $codes : [$this->defaultCurrency()];
}
/**
* 마이페이지 회원 내역을 조회합니다.
*
* @param int $userId 회원 ID
* @param array $filters 필터 조건
* @param int $perPage 페이지당 개수
* @return LengthAwarePaginator 페이지네이터
*/
public function paginateUserHistory(int $userId, array $filters = [], int $perPage = 20): LengthAwarePaginator
{
return $this->ledger->paginateForUser($userId, $filters, $perPage);
}
/**
* 거래 ID 로 연결 거래를 조회합니다 (행 확장용).
*
* @param int $id 거래 ID
* @return Collection|null 연결 거래 (거래 없으면 null)
*/
public function getLinkedTransactions(int $id): ?Collection
{
$transaction = $this->ledger->findById($id);
if ($transaction === null) {
return null;
}
return $this->ledger->getLinkedTransactions($transaction);
}
/**
* 사용자 마일리지 잔액 조회 (화면/주입용 — 캐시 O(1) 조회)
*
* @param int $userId 사용자 ID
* @return array 마일리지 정보 (enabled/available/pending/expiring_soon/expiring_date/total_earned/total_used/by_currency)
*/
public function getBalance(int $userId): array
{
HookManager::doAction('sirsoft-ecommerce.user_mileage.before_balance', $userId);
$cached = $this->cache->getCachedBalance($userId);
$balance = [
'enabled' => (bool) $this->settings->getSetting('mileage.enabled', false),
'available' => $cached['available'] ?? 0,
'pending' => $cached['pending'] ?? 0,
'expiring_soon' => $cached['expiring_soon'] ?? 0,
'expiring_date' => $cached['expiring_date'] ?? null,
'total_earned' => $cached['total_earned'] ?? 0,
'total_used' => $cached['total_used'] ?? 0,
'by_currency' => $cached['by_currency'] ?? [],
];
$balance = HookManager::applyFilters('sirsoft-ecommerce.user_mileage.filter_balance', $balance, $userId);
HookManager::doAction('sirsoft-ecommerce.user_mileage.after_balance', $balance, $userId);
return $balance;
}
/**
* 마일리지 사용 가능 여부 확인 (잔액 기준)
*
* @param int $userId 사용자 ID
* @param int $amount 사용할 마일리지 금액
* @param string|null $currency 통화 코드 (null 시 기본통화)
* @return bool 사용 가능 여부
*/
public function canUse(int $userId, int $amount, ?string $currency = null): bool
{
$currency = $currency ?? $this->defaultCurrency();
return $this->ledger->getBalanceByCurrency($userId, $currency) >= $amount;
}
/**
* 원장 기준 사용 가능 마일리지 잔액 (정수, floor)
*
* getBalance()는 표시 캐시 기준이라 캐시 지연 시 어긋날 수 있으므로,
* 사용 검증/안내 문구는 원장(getBalanceByCurrency) 기준으로 통일합니다.
*
* @param int $userId 사용자 ID
* @param string|null $currency 통화 코드 (null 시 기본통화)
* @return int 원장 기준 가용 잔액
*/
public function availableBalance(int $userId, ?string $currency = null): int
{
$currency = $currency ?? $this->defaultCurrency();
return (int) floor($this->ledger->getBalanceByCurrency($userId, $currency));
}
/**
* 사용 가능한 최대 마일리지 조회 (잔액 + 통화 규칙 캡)
*
* @param int $userId 사용자 ID
* @param int $orderAmount 주문 금액 (사용 한도 계산용)
* @param string|null $currency 통화 코드 (null 시 기본통화)
* @return int 사용 가능한 최대 마일리지
*/
public function getMaxUsable(int $userId, int $orderAmount, ?string $currency = null): int
{
// 기본 통화 사용 규칙 미설정 시 마일리지 사용 불가 (M2)
if (! $this->isMileageUsable()) {
return 0;
}
$currency = $currency ?? $this->defaultCurrency();
$rule = $this->currencyRule($currency);
$balance = (int) floor($this->ledger->getBalanceByCurrency($userId, $currency));
// 최대 사용 한도 (percent / fixed)
$maxByLimit = $this->resolveMaxUseLimit($rule, $orderAmount);
// 결제금액 캡 (사용액은 결제금액을 넘을 수 없음)
$cap = min($balance, $maxByLimit, $orderAmount);
// 사용 단위 내림 보정
$unit = (int) ($rule['use_unit'] ?? 1);
if ($unit > 1) {
$cap = intdiv($cap, $unit) * $unit;
}
// 최소 사용 금액 미만이면 애초에 사용이 불가하므로 안내값도 0 이어야 한다.
// (안내값이 하한을 모르면 "화면이 허용한 값인데 결제에서 거부" 가 된다)
$minUse = (int) ($rule['min_use_amount'] ?? 0);
if ($cap < $minUse) {
return 0;
}
return max(0, $cap);
}
/**
* 마일리지 사용 정책(하한/단위/상한)을 반환합니다 (주문서 안내용).
*
* 검증(validateUsage)이 실제로 적용하는 값과 동일한 출처(통화 규칙)를 사용해,
* 화면 안내와 서버 판정이 어긋나지 않도록 합니다.
*
* @param int $paymentAmount 결제 금액 (정률 상한 계산 기준, 마일리지 차감 전)
* @param string|null $currency 통화 코드 (null 시 기본통화)
* @return array{min_use_amount: int, use_unit: int, max_use_amount: int} 사용 정책
*/
public function getUsagePolicy(int $paymentAmount, ?string $currency = null): array
{
if (! $this->isMileageUsable()) {
return ['min_use_amount' => 0, 'use_unit' => 1, 'max_use_amount' => 0];
}
$rule = $this->currencyRule($currency ?? $this->defaultCurrency());
return [
'min_use_amount' => (int) ($rule['min_use_amount'] ?? 0),
'use_unit' => max(1, (int) ($rule['use_unit'] ?? 1)),
'max_use_amount' => $this->resolveAllowedUseAmount($rule, $paymentAmount),
];
}
/**
* 주문 시점 마일리지 사용 정책 스냅샷을 생성합니다.
*
* 통화/프로모션/배송정책과 동일하게, 주문을 지배한 정책을 주문 행에 고정합니다.
* 관리자가 이후 한도를 바꿔도 그 주문이 어떤 정책 아래 성립했는지 재현 가능해야
* 합니다(정산·감사·분쟁 대응). 사용액 자체는 원장(MileageTransaction)이 SSoT 이며,
* 이 스냅샷은 판정 근거를 보존하는 용도입니다.
*
* @param int $paymentAmount 판정 기준 결제금액 (마일리지 차감 전)
* @param int $usedPoints 실제 사용된 마일리지
* @param string|null $currency 정산 통화 (null 시 기본통화)
* @return array 사용 정책 스냅샷
*/
public function buildUsagePolicySnapshot(int $paymentAmount, int $usedPoints, ?string $currency = null): array
{
$currency = $currency ?? $this->defaultCurrency();
$usable = $this->isMileageUsable();
$rule = $usable ? $this->currencyRule($currency) : [];
// 적립 절사 기준은 사용 가능 여부와 무관하게 기록한다 — 마일리지 "사용" 이 막혀 있어도
// 적립은 별개로 일어나므로, 사용 불가 주문의 재계산에도 절사 기준이 필요하다.
$earnRounding = MileageRounding::normalize($this->currencyRule($currency));
return [
'usable' => $usable,
'currency' => $currency,
'rule' => [
'point_value' => (float) ($rule['point_value'] ?? 1),
'min_use_amount' => (int) ($rule['min_use_amount'] ?? 0),
'use_unit' => max(1, (int) ($rule['use_unit'] ?? 1)),
'max_use_type' => (string) ($rule['max_use_type'] ?? 'percent'),
'max_use_percent' => (float) ($rule['max_use_percent'] ?? 100),
'max_use_value' => (int) ($rule['max_use_value'] ?? 0),
MileageRounding::UNIT_KEY => $earnRounding['unit'],
MileageRounding::METHOD_KEY => $earnRounding['method'],
],
// 판정 근거 — 이 결제금액에 이 정책을 적용해 아래 상한이 나왔고, 그 안에서 사용됐다.
'payment_amount_basis' => $paymentAmount,
'max_use_amount' => $usable ? $this->resolveAllowedUseAmount($rule, $paymentAmount) : 0,
'used_points' => $usedPoints,
];
}
/**
* 마일리지 사용 검증 (min/use_unit/max/잔액) → 보정값 반환 또는 예외
*
* @param int $userId 사용자 ID
* @param int $usePoints 사용 요청 마일리지
* @param int $paymentAmount 결제 금액 (마일리지 차감 전)
* @param string|null $currency 통화 코드 (null 시 기본통화 — canUse/getMaxUsable 과 동일 기준)
* @return int 검증/보정된 사용 마일리지
*
* @throws MileageValidationException 검증 실패 시
*/
public function validateUsage(int $userId, int $usePoints, int $paymentAmount, ?string $currency = null): int
{
if ($usePoints <= 0) {
return 0;
}
// 기본 통화 사용 규칙 미설정 시 마일리지 사용 불가 (M2)
if (! $this->isMileageUsable()) {
throw new MileageValidationException(
__('sirsoft-ecommerce::exceptions.mileage.base_currency_rule_missing')
);
}
$currency = $currency ?? $this->defaultCurrency();
$rule = $this->currencyRule($currency);
$minUse = (int) ($rule['min_use_amount'] ?? 0);
if ($usePoints < $minUse) {
throw new MileageValidationException(
__('sirsoft-ecommerce::exceptions.mileage.below_min_use_amount', ['amount' => $minUse])
);
}
$unit = (int) ($rule['use_unit'] ?? 1);
if ($unit > 1 && $usePoints % $unit !== 0) {
throw new MileageValidationException(
__('sirsoft-ecommerce::exceptions.mileage.invalid_use_unit', ['unit' => $unit])
);
}
$maxByLimit = $this->resolveMaxUseLimit($rule, $paymentAmount);
if ($usePoints > $maxByLimit || $usePoints > $paymentAmount) {
// 사용 가능한 최대 금액을 함께 안내 — 얼마로 줄여야 결제되는지 알 수 없으면
// 사용자는 값을 낮춰가며 재시도할 수밖에 없다.
throw new MileageValidationException(
__('sirsoft-ecommerce::exceptions.mileage.exceeds_max_use', [
'amount' => $this->resolveAllowedUseAmount($rule, $paymentAmount),
])
);
}
if ($this->ledger->getBalanceByCurrency($userId, $currency) < $usePoints) {
throw new MileageValidationException(
__('sirsoft-ecommerce::exceptions.mileage.insufficient_balance')
);
}
return $usePoints;
}
/**
* 주문옵션 적립 (존재검사 → subtotal_earned_points_amount 기준 적립)
*
* @param Order $order 주문
* @param OrderOption $option 주문옵션
* @param MileageTransactionTypeEnum $type 적립 유형
* @return MileageTransaction|null 생성된 적립 거래 (이미 적립됐거나 0원이면 null)
*/
public function earnForOrderOption(Order $order, OrderOption $option, MileageTransactionTypeEnum $type): ?MileageTransaction
{
if ($order->user_id === null) {
return null;
}
// 금액 델타 멱등: 목표 적립액(옵션 subtotal_earned) − 기적립 purchase_earn 합계 = 델타만 적립.
// 나눠 확정·병합으로 목표액이 늘면 차액을 추가 지급하고, 단순 재확정·스케줄러 재실행은 델타 0 으로 no-op.
$target = (float) $option->subtotal_earned_points_amount;
$alreadyEarned = $this->ledger->sumPurchaseEarnedForOption($option->id);
$amount = $target - $alreadyEarned;
if ($amount <= 0) {
return null;
}
$currency = $this->baseCurrencyForOrder($order);
// 방식 A: 기존 purchase_earn lot 이 있으면 그 lot 에 델타를 증액(적립 내역 한 줄 유지).
// 취소 회수(findEarnLotForOption 단일 lot 가정)·유효기간 정합을 위해 신규 lot 을 늘리지 않는다.
if ($type === MileageTransactionTypeEnum::PURCHASE_EARN
&& ($existingLot = $this->ledger->findEarnLotForOption($option->id)) !== null) {
$this->ledger->incrementEarnLotAmount($existingLot, $amount);
$this->cache->recalculateForUser($order->user_id, $currency);
$this->cache->recalculatePending($order->user_id, $currency);
$this->logActivity('mileage.earn', [
'loggable' => $existingLot,
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_earn',
'description_params' => ['amount' => (int) $amount],
'properties' => ['order_id' => $order->id, 'order_option_id' => $option->id, 'currency' => $currency, 'delta' => (int) $amount],
]);
return $existingLot;
}
$expiresAt = $this->resolveEarnExpiry();
$tx = $this->ledger->createTransaction([
'user_id' => $order->user_id,
'currency' => $currency,
'type' => $type->value,
'amount' => $amount,
'remaining_amount' => $amount,
'balance_after' => $this->ledger->getBalanceByCurrency($order->user_id, $currency) + $amount,
'order_id' => $order->id,
'order_option_id' => $option->id,
'expires_at' => $expiresAt,
'description' => __('sirsoft-ecommerce::activity_log.description.mileage_earn', ['amount' => $amount]),
]);
$this->cache->recalculateForUser($order->user_id, $currency);
$this->cache->recalculatePending($order->user_id, $currency);
$this->logActivity('mileage.earn', [
'loggable' => $tx,
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_earn',
'description_params' => ['amount' => (int) $amount],
'properties' => ['order_id' => $order->id, 'order_option_id' => $option->id, 'currency' => $currency],
]);
return $tx;
}
/**
* FIFO 차감 (FOR UPDATE → 재검증 → lot 소비)
*
* @param int $userId 사용자 ID
* @param int $amount 차감액
* @param string $currency 통화 코드
* @param Order $order 주문
* @return MileageTransaction 생성된 사용 거래 (음수)
*
* @throws MileageValidationException 잔액 부족 시
*/
public function deductFifo(int $userId, int $amount, string $currency, Order $order): MileageTransaction
{
return $this->consumeFifo(
$userId,
$amount,
$currency,
MileageTransactionTypeEnum::ORDER_USE,
['order_id' => $order->id],
null,
null,
);
}
/**
* 사용 복원 (mileage.restore payload 기반, order_cancel_id 멱등, 신규 lot)
*
* @param int $userId 사용자 ID
* @param int $orderId 주문 ID
* @param int $orderCancelId 주문취소 ID (멱등 기준)
* @param float $amount 복원액
* @param string $currency 통화 코드
* @return MileageTransaction|null 복원 거래 (이미 복원됐거나 0원이면 null)
*/
public function restoreForCancel(int $userId, int $orderId, int $orderCancelId, float $amount, string $currency): ?MileageTransaction
{
if ($this->ledger->existsRestoreForCancel($orderCancelId)) {
return null;
}
if ($amount <= 0) {
return null;
}
// 복원 lot 은 원본 expires_at 승계가 아닌 신규 lot (복원 시점 + expiry_days)
$expiresAt = $this->resolveEarnExpiry();
$tx = $this->ledger->createTransaction([
'user_id' => $userId,
'currency' => $currency,
'type' => MileageTransactionTypeEnum::ORDER_CANCEL_RESTORE->value,
'amount' => $amount,
'remaining_amount' => $amount,
'balance_after' => $this->ledger->getBalanceByCurrency($userId, $currency) + $amount,
'order_id' => $orderId,
'order_cancel_id' => $orderCancelId,
'expires_at' => $expiresAt,
'description' => __('sirsoft-ecommerce::activity_log.description.mileage_restore', ['amount' => $amount]),
]);
// 원 사용 거래에 동일 order_cancel_id 역주입 — 복원 ↔ 원 사용 거래 연결 (행 확장 정합)
$this->ledger->linkUsageToCancel($orderId, $orderCancelId);
$this->cache->recalculateForUser($userId, $currency);
$this->logActivity('mileage.restore', [
'loggable' => $tx,
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_restore',
'description_params' => ['amount' => (int) $amount],
'properties' => ['order_id' => $orderId, 'order_cancel_id' => $orderCancelId, 'currency' => $currency],
]);
return $tx;
}
/**
* 결제 실패/결제창 닫기로 인한 주문 취소 시 사용 마일리지를 복원합니다.
*
* 정식 주문취소(OrderCancel 레코드 + 환불 정산)와 달리, 결제 완료 전 주문이
* failPayment 로 취소되는 경로는 OrderCancel 이 없다. 따라서 order_cancel_id 없이
* 주문 단위로 복원하며, 멱등성은 호출자(OrderProcessingService::failPayment)가
* is_mileage_deducted 플래그 + 결제 전 상태 가드로 보장한다 (복원 후 플래그 해제).
* 복원 lot 은 신규 발행(복원 시점 + expiry_days) — 정식 취소 복원과 동일 정책.
*
* @param int $userId 사용자 ID
* @param int $orderId 주문 ID
* @param float $amount 복원액
* @param string $currency 통화 코드
* @return MileageTransaction|null 복원 거래 (0원이면 null)
*/
public function restoreForFailedPayment(int $userId, int $orderId, float $amount, string $currency): ?MileageTransaction
{
if ($amount <= 0) {
return null;
}
$expiresAt = $this->resolveEarnExpiry();
$tx = $this->ledger->createTransaction([
'user_id' => $userId,
'currency' => $currency,
'type' => MileageTransactionTypeEnum::ORDER_CANCEL_RESTORE->value,
'amount' => $amount,
'remaining_amount' => $amount,
'balance_after' => $this->ledger->getBalanceByCurrency($userId, $currency) + $amount,
'order_id' => $orderId,
'expires_at' => $expiresAt,
'description' => __('sirsoft-ecommerce::activity_log.description.mileage_restore', ['amount' => $amount]),
]);
$this->cache->recalculateForUser($userId, $currency);
$this->logActivity('mileage.restore', [
'loggable' => $tx,
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_restore',
'description_params' => ['amount' => (int) $amount],
'properties' => ['order_id' => $orderId, 'currency' => $currency, 'reason' => 'payment_failed'],
]);
return $tx;
}
/**
* 적립 회수 (취소 전이 시 기적립건 회수 — §2 부족 회수 정책)
*
* @param Order $order 주문
* @param OrderOption $option 주문옵션
* @return MileageTransaction|null 회수 거래 (회수 대상 없으면 null)
*/
public function cancelEarnForOption(Order $order, OrderOption $option): ?MileageTransaction
{
if ($order->user_id === null) {
return null;
}
// 해당 옵션 적립건 조회
$earnLot = $this->ledger->findEarnLotForOption($option->id);
if ($earnLot === null) {
return null;
}
$currency = $this->baseCurrencyForOrder($order);
$toRecover = (float) $earnLot->amount;
return DB::transaction(function () use ($order, $option, $earnLot, $currency, $toRecover) {
$shortfall = $this->recoverPoints($order->user_id, $currency, $toRecover, $earnLot);
$tx = $this->ledger->createTransaction([
'user_id' => $order->user_id,
'currency' => $currency,
'type' => MileageTransactionTypeEnum::EARN_CANCEL->value,
'amount' => -($toRecover - $shortfall),
'remaining_amount' => 0,
'balance_after' => $this->ledger->getBalanceByCurrency($order->user_id, $currency),
'order_id' => $order->id,
'order_option_id' => $option->id,
'source_transaction_id' => $earnLot->id,
'metadata' => $shortfall > 0 ? ['shortfall' => $shortfall] : null,
'description' => __('sirsoft-ecommerce::activity_log.description.mileage_earn_cancel', ['amount' => $toRecover]),
]);
$this->cache->recalculateForUser($order->user_id, $currency);
$this->cache->recalculatePending($order->user_id, $currency);
$this->logActivity('mileage.earn_cancel', [
'loggable' => $tx,
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_earn_cancel',
'description_params' => ['amount' => (int) $toRecover],
'properties' => [
'order_id' => $order->id,
'order_option_id' => $option->id,
'currency' => $currency,
'shortfall' => $shortfall,
],
]);
return $tx;
});
}
/**
* 관리자 수동 지급
*
* @param int $userId 대상 사용자 ID
* @param MileageAdminEarnDto $dto 지급 DTO
* @return MileageTransaction 생성된 지급 거래
*/
public function adminEarn(int $userId, MileageAdminEarnDto $dto): MileageTransaction
{
return DB::transaction(function () use ($userId, $dto) {
$expiresAt = $this->resolveAdminEarnExpiry($dto);
$tx = $this->ledger->createTransaction([
'user_id' => $userId,
'currency' => $dto->currency,
'type' => MileageTransactionTypeEnum::ADMIN_EARN->value,
'amount' => $dto->amount,
'remaining_amount' => $dto->amount,
'balance_after' => $this->ledger->getBalanceByCurrency($userId, $dto->currency) + $dto->amount,
'granted_by' => $dto->grantedBy,
'memo' => $dto->memo,
'expires_at' => $expiresAt,
'description' => $dto->description
?? __('sirsoft-ecommerce::activity_log.description.mileage_admin_earn', ['amount' => $dto->amount]),
]);
$this->cache->recalculateForUser($userId, $dto->currency);
$this->logActivity('mileage.admin_earn', [
'loggable' => $tx,
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_admin_earn',
'description_params' => ['amount' => $dto->amount],
'properties' => ['granted_by' => $dto->grantedBy, 'currency' => $dto->currency],
]);
return $tx;
});
}
/**
* 관리자 수동 차감 (FIFO 소비 + 잔액 한도)
*
* @param int $userId 대상 사용자 ID
* @param MileageAdminDeductDto $dto 차감 DTO
* @return MileageTransaction 생성된 차감 거래 (음수)
*
* @throws MileageValidationException 잔액 초과 차감 시
*/
public function adminDeduct(int $userId, MileageAdminDeductDto $dto): MileageTransaction
{
return $this->consumeFifo(
$userId,
$dto->amount,
$dto->currency,
MileageTransactionTypeEnum::ADMIN_DEDUCT,
['granted_by' => $dto->grantedBy, 'memo' => $dto->memo],
$dto->grantedBy,
$dto->memo,
'deduct_exceeds_balance',
);
}
/**
* 일괄 유효기간 연장 (지급계 lot 의 expires_at 갱신)
*
* @param int $userId 대상 사용자 ID
* @param array<int, int> $lotIds 연장 대상 lot ID 목록
* @param int $days 연장 일수
* @return int 연장된 lot 수
*/
public function extendLotExpiry(int $userId, array $lotIds, int $days): int
{
return DB::transaction(function () use ($userId, $lotIds, $days) {
$lots = $this->ledger->getRemainingLotsByIds($userId, $lotIds);
$currencies = [];
foreach ($lots as $lot) {
$base = $lot->expires_at !== null ? $lot->expires_at->copy() : now();
$this->ledger->updateExpiry($lot, $base->addDays($days));
$currencies[$lot->currency] = true;
}
foreach (array_keys($currencies) as $currency) {
$this->cache->recalculateForUser($userId, $currency);
}
if ($lots->isNotEmpty()) {
$this->logActivity('mileage.extend_expiry', [
'loggable' => $lots->first(),
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_extend_expiry',
'description_params' => ['days' => $days],
'properties' => ['user_id' => $userId, 'lot_count' => $lots->count(), 'days' => $days],
]);
}
return $lots->count();
});
}
/**
* 관리자 적립건 편집 — 사유(memo) 변경 + 만료일(expires_at) 직접 지정.
*
* 마일리지 원장은 불변이므로 거래를 삭제하지 않고, 적립계 거래의 부가 필드만 보정한다.
* 적립계가 아닌 거래는 편집 불가(not_earning). 이미 소멸(expired_at 존재)되었거나 전액
* 사용(remaining 0)된 적립건은 만료일 변경 불가(expiry_not_editable) — memo 만 변경 가능.
*
* @param int $id 거래 ID
* @param string|null $memo 변경할 관리자 메모 (null 이면 미변경 — Controller 가 키 존재로 판정)
* @param Carbon|null $expiresAt 변경할 만료일 (null 이면 미변경)
* @param bool $touchMemo memo 키 전달 여부 (null 로 지우기와 미전달 구분)
* @param bool $touchExpiry expires_at 키 전달 여부
* @return MileageTransaction 갱신된 거래
*
* @throws MileageValidationException 적립계 아님 / 만료일 변경 불가
*/
public function updateAdminTransaction(
int $id,
?string $memo,
?Carbon $expiresAt,
bool $touchMemo,
bool $touchExpiry
): MileageTransaction {
return DB::transaction(function () use ($id, $memo, $expiresAt, $touchMemo, $touchExpiry) {
$transaction = $this->ledger->findById($id);
if ($transaction === null) {
throw new MileageValidationException(__('sirsoft-ecommerce::messages.mileage.not_found'));
}
$type = $transaction->type instanceof MileageTransactionTypeEnum
? $transaction->type
: MileageTransactionTypeEnum::tryFrom((string) $transaction->type);
if (! ($type?->isEarning() ?? false)) {
throw new MileageValidationException(__('sirsoft-ecommerce::messages.mileage.not_earning'));
}
$fields = [];
if ($touchMemo) {
$fields['memo'] = $memo;
}
if ($touchExpiry) {
$isExpiredOrUsedUp = $transaction->expired_at !== null
|| (float) $transaction->remaining_amount <= 0;
if ($isExpiredOrUsedUp) {
throw new MileageValidationException(__('sirsoft-ecommerce::messages.mileage.expiry_not_editable'));
}
// 만료일은 적립일시(created_at)보다 이전일 수 없다 (무기한 = null 은 허용)
if ($expiresAt !== null
&& $transaction->created_at instanceof Carbon
&& $expiresAt->lessThan($transaction->created_at)
) {
throw new MileageValidationException(__('sirsoft-ecommerce::messages.mileage.expiry_before_earned'));
}
$fields['expires_at'] = $expiresAt;
}
$transaction = $this->ledger->updateAdminFields($transaction, $fields);
// 만료일 변경 시 소멸 임박 윈도우 캐시 영향 — 사용자 통화 재계산
if ($touchExpiry) {
$this->cache->recalculateForUser($transaction->user_id, $transaction->currency);
}
$this->logActivity('mileage.adjust', [
'loggable' => $transaction,
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_adjust',
'description_params' => ['amount' => (int) abs((float) $transaction->amount)],
'properties' => [
'user_id' => $transaction->user_id,
'currency' => $transaction->currency,
'memo_changed' => $touchMemo,
'expiry_changed' => $touchExpiry,
],
]);
return $transaction;
});
}
/**
* 만료 lot 소멸 처리 (배치용 — 만료 lot마다 per-item expired 거래 + expired_at)
*
* @param Carbon $now 기준 시각
* @param int|null $limit 최대 처리 건수
* @return int 소멸 처리된 lot 수
*/
public function expireLots(Carbon $now, ?int $limit = null): int
{
$lots = $this->ledger->getExpiringLots($now, $limit);
$affected = [];
foreach ($lots as $lot) {
DB::transaction(function () use ($lot, $now, &$affected) {
$remaining = (float) $lot->remaining_amount;
$tx = $this->ledger->createTransaction([
'user_id' => $lot->user_id,
'currency' => $lot->currency,
'type' => MileageTransactionTypeEnum::EXPIRED->value,
'amount' => -$remaining,
'remaining_amount' => 0,
'balance_after' => $this->ledger->getBalanceByCurrency($lot->user_id, $lot->currency) - $remaining,
'source_transaction_id' => $lot->id,
'expired_at' => $now,
'description' => __('sirsoft-ecommerce::activity_log.description.mileage_expire', ['amount' => $remaining]),
]);
$this->ledger->markExpired($lot, $now);
$this->cache->recalculateForUser($lot->user_id, $lot->currency);
$this->logActivity('mileage.expire', [
'loggable' => $tx,
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_expire',
'description_params' => ['amount' => (int) $remaining],
'properties' => ['user_id' => $lot->user_id, 'currency' => $lot->currency],
]);
$affected[] = $lot->id;
});
}
return count($affected);
}
/**
* 회원 탈퇴/삭제 시 활성 lot 소멸 기록 (감사 흐름 보존 — 정책 확정 #8)
*
* 활성 lot 을 expired 거래로 소멸 기록(활동로그에 금액·통화·회원ID 잔존)합니다.
* 거래/캐시 행은 이후 cascade 또는 명시 삭제로 함께 사라집니다.
*
* @param int $userId 탈퇴 사용자 ID
*/
public function recordWithdrawalExpiry(int $userId): void
{
$lots = $this->ledger->getActiveLotsForUser($userId);
$now = now();
foreach ($lots as $lot) {
$remaining = (float) $lot->remaining_amount;
if ($remaining <= 0) {
continue;
}
$this->logActivity('mileage.expire', [
'description_key' => 'sirsoft-ecommerce::activity_log.description.mileage_expire',
'description_params' => ['amount' => (int) $remaining],
'properties' => [
'user_id' => $userId,
'currency' => $lot->currency,
'reason' => 'withdrawal',
],
]);
$this->ledger->markExpired($lot, $now);
}
}
/**
* 회원 탈퇴/삭제 시 마일리지 데이터를 명시적으로 정리합니다 (정책 확정 #8).
*
* 활성 lot 을 expired 거래로 소멸 기록(활동로그 보존)한 뒤, 원장·잔액 캐시 행을
* 동일 트랜잭션에서 삭제합니다. CASCADE 는 안전망으로만 두고 명시 삭제합니다
* (탈퇴는 soft 상태 변경이라 cascadeOnDelete 가 발화하지 않으므로 명시 삭제 필수).
*
* @param int $userId 탈퇴/삭제 회원 ID
*/
public function purgeForUser(int $userId): void
{
DB::transaction(function () use ($userId) {
$this->recordWithdrawalExpiry($userId);
$this->ledger->deleteForUser($userId);
$this->cache->deleteForUser($userId);
});
}
/**
* FIFO 차감 공통 처리 (사용/관리자 차감)
*
* @param int $userId 사용자 ID
* @param int $amount 차감액
* @param string $currency 통화 코드
* @param MileageTransactionTypeEnum $type 거래 유형
* @param array $extra 거래 추가 필드 (order_id/granted_by/memo)
* @param int|null $grantedBy 부여 관리자 ID (활동로그용)
* @param string|null $memo 메모
* @param string $insufficientKey 잔액 부족 예외 메시지 키
* @return MileageTransaction 생성된 차감 거래
*
* @throws MileageValidationException 잔액 부족 시
*/
private function consumeFifo(
int $userId,
int $amount,
string $currency,
MileageTransactionTypeEnum $type,
array $extra,
?int $grantedBy,
?string $memo,
string $insufficientKey = 'insufficient_balance',
): MileageTransaction {
return DB::transaction(function () use ($userId, $amount, $currency, $type, $extra, $grantedBy, $insufficientKey) {
$lots = $this->ledger->getActiveLotsForUpdate($userId, $currency);
$available = $lots->sum(fn ($lot) => (float) $lot->remaining_amount);
if ($available < $amount) {
throw new MileageValidationException(
__("sirsoft-ecommerce::exceptions.mileage.{$insufficientKey}")
);
}
$remaining = (float) $amount;
$firstSource = null;
foreach ($lots as $lot) {
if ($remaining <= 0) {
break;
}
$take = min((float) $lot->remaining_amount, $remaining);
$this->ledger->decrementRemaining($lot, $take);
$remaining -= $take;
$firstSource ??= $lot->id;
}
// 지급(adminEarn)은 description 을 채우는데 차감·사용은 비워 두면
// 회원 마일리지 내역의 「내용」 열이 음수 행에서만 공백이 된다.
// 활동 로그와 같은 키를 써서 지급/차감 표기를 대칭으로 맞춘다.
$descriptionKey = $type === MileageTransactionTypeEnum::ADMIN_DEDUCT
? 'sirsoft-ecommerce::activity_log.description.mileage_admin_deduct'
: 'sirsoft-ecommerce::activity_log.description.mileage_use';
$tx = $this->ledger->createTransaction(array_merge([
'user_id' => $userId,
'currency' => $currency,
'type' => $type->value,
'amount' => -$amount,
'remaining_amount' => 0,
'balance_after' => $this->ledger->getBalanceByCurrency($userId, $currency),
'source_transaction_id' => $firstSource,
'description' => __($descriptionKey, ['amount' => $amount]),
], $extra));
$this->cache->recalculateForUser($userId, $currency);
$action = $type === MileageTransactionTypeEnum::ADMIN_DEDUCT ? 'mileage.admin_deduct' : 'mileage.use';
$descKey = $type === MileageTransactionTypeEnum::ADMIN_DEDUCT
? 'sirsoft-ecommerce::activity_log.description.mileage_admin_deduct'
: 'sirsoft-ecommerce::activity_log.description.mileage_use';
$this->logActivity($action, [
'loggable' => $tx,
'description_key' => $descKey,
'description_params' => ['amount' => $amount],
'properties' => array_merge(['currency' => $currency], $grantedBy !== null ? ['granted_by' => $grantedBy] : []),
]);
return $tx;
});
}
/**
* 적립 회수 시 lot 잔여를 우선 차감 → 타 lot FIFO → 부족분 반환 (§2 정책)
*
* @param int $userId 사용자 ID
* @param string $currency 통화 코드
* @param float $amount 회수액
* @param MileageTransaction $earnLot 대상 적립건
* @return float 회수하지 못한 부족액
*/
private function recoverPoints(int $userId, string $currency, float $amount, MileageTransaction $earnLot): float
{
$remaining = $amount;
// 1) 해당 earn lot 잔여 우선 차감
$fromSelf = min((float) $earnLot->remaining_amount, $remaining);
if ($fromSelf > 0) {
$this->ledger->decrementRemaining($earnLot, $fromSelf);
$remaining -= $fromSelf;
}
// 2) 타 lot FIFO 차감
if ($remaining > 0) {
$lots = $this->ledger->getActiveLotsForUpdate($userId, $currency);
foreach ($lots as $lot) {
if ($remaining <= 0) {
break;
}
if ($lot->id === $earnLot->id) {
continue;
}
$take = min((float) $lot->remaining_amount, $remaining);
$this->ledger->decrementRemaining($lot, $take);
$remaining -= $take;
}
}
// 3) 부족분 반환 (음수 잔액 구조적 불가)
return max(0, $remaining);
}
/**
* 적립 만료일을 설정값 기준으로 계산합니다.
*
* @return Carbon|null 만료일 (유효기간 비활성 시 null)
*/
private function resolveEarnExpiry(): ?Carbon
{
if (! (bool) $this->settings->getSetting('mileage.expiry_enabled', true)) {
return null;
}
$days = (int) $this->settings->getSetting('mileage.expiry_days', 365);
return now()->addDays($days);
}
/**
* 관리자 지급 만료일을 계산합니다.
*
* @param MileageAdminEarnDto $dto 지급 DTO
* @return Carbon|null 만료일
*/
private function resolveAdminEarnExpiry(MileageAdminEarnDto $dto): ?Carbon
{
if ($dto->expiresAt !== null) {
return Carbon::parse($dto->expiresAt);
}
if (! $dto->useDefaultExpiry) {
return null;
}
return $this->resolveEarnExpiry();
}
/**
* 통화별 최대 사용 한도를 계산합니다 (percent / fixed).
*
* @param array $rule 통화 규칙
* @param int $paymentAmount 결제 금액
* @return int 최대 사용 한도
*/
private function resolveMaxUseLimit(array $rule, int $paymentAmount): int
{
$type = $rule['max_use_type'] ?? 'percent';
if ($type === 'percent') {
$percent = (float) ($rule['max_use_percent'] ?? 100);
return (int) floor($paymentAmount * $percent / 100);
}
return (int) ($rule['max_use_value'] ?? PHP_INT_MAX);
}
/**
* 실제로 사용 가능한 최대 금액을 계산합니다 (한도 · 결제금액 · 사용단위 반영).
*
* 잔액은 반영하지 않습니다 — 정책상 허용 한도이며, 잔액 부족은 별도 사유로 구분됩니다.
*
* @param array $rule 통화 규칙
* @param int $paymentAmount 결제 금액 (마일리지 차감 전)
* @return int 정책상 사용 가능한 최대 금액
*/
private function resolveAllowedUseAmount(array $rule, int $paymentAmount): int
{
$allowed = min($this->resolveMaxUseLimit($rule, $paymentAmount), $paymentAmount);
$unit = (int) ($rule['use_unit'] ?? 1);
if ($unit > 1) {
$allowed = intdiv($allowed, $unit) * $unit;
}
return max(0, $allowed);
}
/**
* 통화 규칙을 조회합니다.
*
* @param string $currency 통화 코드
* @return array 통화 규칙
*/
private function currencyRule(string $currency): array
{
$rules = (array) $this->settings->getSetting('mileage.currency_rules', []);
foreach ($rules as $rule) {
if (($rule['currency_code'] ?? null) === $currency) {
return $rule;
}
}
return $rules[0] ?? [];
}
/**
* 기본 통화를 반환합니다.
*
* @return string 기본 통화 코드
*/
private function defaultCurrency(): string
{
$rules = (array) $this->settings->getSetting('mileage.currency_rules', []);
return $rules[0]['currency_code'] ?? 'KRW';
}
/**
* 마일리지 사용이 가능한지 여부를 반환합니다.
*
* 기본 통화(base_currency)에 대한 마일리지 통화별 사용 단위 규칙이 설정되어 있지 않으면
* 마일리지 사용을 허용하지 않는다(정책). 마일리지는 base_currency 기준으로 사용/정산되므로
* base 규칙이 없으면 사용 단위·최소 사용액·한도를 알 수 없어 사용 자체가 불가하다.
*
* @return bool 기본 통화 마일리지 사용 규칙이 설정되어 있으면 true
*/
public function isMileageUsable(): bool
{
$rules = (array) $this->settings->getSetting('mileage.currency_rules', []);
if ($rules === []) {
return false;
}
$base = $this->settings->getSetting('language_currency.default_currency', null)
?? ($rules[0]['currency_code'] ?? null);
if (! is_string($base) || $base === '') {
return false;
}
foreach ($rules as $rule) {
if (($rule['currency_code'] ?? null) === $base) {
return true;
}
}
return false;
}
/**
* 주문의 마일리지 정산 통화(기본화폐 base_currency)를 반환합니다.
*
* 마일리지는 주문서/PG 정산과 동일하게 base_currency 기준으로 적립·사용·복원된다.
* 유저가 선택한 표시통화(order_currency/X-Currency)와 무관하다 — 표시통화는 화면 환산 전용.
* 주문 통화 스냅샷의 base_currency 를 우선 사용하고, 없으면 마일리지 설정의 기본 통화로 폴백.
*
* @param Order $order 주문
* @return string 기준통화 코드
*/
public function baseCurrencyForOrder(Order $order): string
{
$snapshot = $order->currency_snapshot ?? [];
return $snapshot['base_currency'] ?? $this->defaultCurrency();
}
}