feat:법인카드 요약카드 개선 (결제일/사용금액/선불결제)

- 요약카드 4개→6개 확장 (등록카드, 총한도, 매월결제일, 사용금액, 선불결제, 잔여한도)
- 매월결제일: 휴일/주말 시 다음 영업일로 자동 조정 표시
- 사용금액: barobill_card_transactions 기반 청구기간 실거래 합산
- 선불결제: 수정 모달로 테넌트 단위 월별 금액 관리
- 잔여한도: (총한도 - 사용금액 + 선불결제) 계산

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-11 10:24:39 +09:00
parent 9167d676f3
commit d78d431350
4 changed files with 395 additions and 24 deletions

View File

@@ -3,7 +3,12 @@
namespace App\Http\Controllers\Finance;
use App\Http\Controllers\Controller;
use App\Models\Barobill\CardTransaction;
use App\Models\Barobill\CardTransactionHide;
use App\Models\Finance\CorporateCard;
use App\Models\Finance\CorporateCardPrepayment;
use App\Models\System\Holiday;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -179,4 +184,185 @@ public function destroy(int $id): JsonResponse
'message' => '카드가 영구 삭제되었습니다.',
]);
}
/**
* 요약 데이터 (결제일, 사용금액, 선불결제)
*/
public function summary(): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$now = Carbon::now();
// 활성 카드 조회
$activeCards = CorporateCard::forTenant($tenantId)->active()->get();
if ($activeCards->isEmpty()) {
return response()->json([
'success' => true,
'data' => [
'paymentDate' => null,
'paymentDay' => 15,
'originalDate' => null,
'isAdjusted' => false,
'billingPeriod' => null,
'billingUsage' => 0,
'prepaidAmount' => 0,
'prepaidMemo' => '',
],
]);
}
// 대표 결제일 (첫 번째 활성 신용카드 기준)
$creditCard = $activeCards->firstWhere('card_type', 'credit');
$paymentDay = $creditCard ? $creditCard->payment_day : 15;
// 휴일 조정 결제일 계산
$originalDate = $this->createPaymentDate($now->year, $now->month, $paymentDay);
$adjustedDate = $this->getAdjustedPaymentDate($tenantId, $now->year, $now->month, $paymentDay);
$isAdjusted = !$originalDate->isSameDay($adjustedDate);
// 청구기간: 전월 1일 ~ 당월 결제일(조정후)
$billingStart = $now->copy()->subMonth()->startOfMonth();
$billingEnd = $adjustedDate->copy();
// 카드번호 목록
$cardNumbers = $activeCards->pluck('card_number')->toArray();
// 사용금액 계산
$billingUsage = $this->calculateBillingUsage(
$tenantId,
$billingStart->format('Y-m-d'),
$billingEnd->format('Y-m-d'),
$cardNumbers
);
// 선불결제 금액
$yearMonth = $now->format('Y-m');
$prepayment = CorporateCardPrepayment::getOrCreate($tenantId, $yearMonth);
return response()->json([
'success' => true,
'data' => [
'paymentDate' => $adjustedDate->format('Y-m-d'),
'paymentDay' => $paymentDay,
'originalDate' => $originalDate->format('Y-m-d'),
'isAdjusted' => $isAdjusted,
'billingPeriod' => [
'start' => $billingStart->format('Y-m-d'),
'end' => $billingEnd->format('Y-m-d'),
],
'billingUsage' => $billingUsage,
'prepaidAmount' => (int) $prepayment->amount,
'prepaidMemo' => $prepayment->memo ?? '',
],
]);
}
/**
* 선불결제 금액 수정
*/
public function updatePrepayment(Request $request): JsonResponse
{
$request->validate([
'amount' => 'required|integer|min:0',
'memo' => 'nullable|string|max:200',
]);
$tenantId = session('selected_tenant_id', 1);
$yearMonth = Carbon::now()->format('Y-m');
$prepayment = CorporateCardPrepayment::updateOrCreate(
['tenant_id' => $tenantId, 'year_month' => $yearMonth],
['amount' => $request->input('amount'), 'memo' => $request->input('memo')]
);
return response()->json([
'success' => true,
'message' => '선불결제 금액이 저장되었습니다.',
'data' => [
'amount' => (int) $prepayment->amount,
'memo' => $prepayment->memo ?? '',
],
]);
}
/**
* 결제일 날짜 생성 (월 말일 초과 방지)
*/
private function createPaymentDate(int $year, int $month, int $day): Carbon
{
$maxDay = Carbon::create($year, $month)->daysInMonth;
return Carbon::create($year, $month, min($day, $maxDay));
}
/**
* 휴일/주말 조정된 결제일 계산
*/
private function getAdjustedPaymentDate(int $tenantId, int $year, int $month, int $paymentDay): Carbon
{
$date = $this->createPaymentDate($year, $month, $paymentDay);
// 해당 기간의 휴일 조회
$holidays = Holiday::forTenant($tenantId)
->where('start_date', '<=', $date->copy()->addDays(10)->format('Y-m-d'))
->where('end_date', '>=', $date->format('Y-m-d'))
->get();
$holidayDates = [];
foreach ($holidays as $h) {
$current = $h->start_date->copy();
while ($current <= $h->end_date) {
$holidayDates[] = $current->format('Y-m-d');
$current->addDay();
}
}
// 토/일/공휴일이면 다음 영업일로 이동
while ($date->isWeekend() || in_array($date->format('Y-m-d'), $holidayDates)) {
$date->addDay();
}
return $date;
}
/**
* 청구기간 사용금액 계산 (바로빌 카드거래 합산)
*/
private function calculateBillingUsage(int $tenantId, string $startDate, string $endDate, array $cardNumbers): int
{
// 카드번호 정규화 (하이픈 제거)
$normalizedNums = array_map(fn($num) => str_replace('-', '', $num), $cardNumbers);
if (empty($normalizedNums)) {
return 0;
}
// use_date는 YYYYMMDD 형식
$startYmd = str_replace('-', '', $startDate);
$endYmd = str_replace('-', '', $endDate);
// 숨긴 거래 키 조회
$hiddenKeys = CardTransactionHide::getHiddenKeys($tenantId, $startYmd, $endYmd);
// 바로빌 카드거래 조회
$transactions = CardTransaction::where('tenant_id', $tenantId)
->whereIn('card_num', $normalizedNums)
->whereBetween('use_date', [$startYmd, $endYmd])
->get();
$total = 0;
foreach ($transactions as $tx) {
if ($hiddenKeys->contains($tx->unique_key)) {
continue;
}
if ($tx->approval_type === '1') {
$total += (int) $tx->approval_amount; // 승인
} elseif ($tx->approval_type === '2') {
$total -= (int) $tx->approval_amount; // 취소
}
}
return $total;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models\Finance;
use Illuminate\Database\Eloquent\Model;
class CorporateCardPrepayment extends Model
{
protected $table = 'corporate_card_prepayments';
protected $fillable = ['tenant_id', 'year_month', 'amount', 'memo'];
public function scopeForTenant($query, int $tenantId)
{
return $query->where('tenant_id', $tenantId);
}
public static function getOrCreate(int $tenantId, string $yearMonth): self
{
return static::firstOrCreate(
['tenant_id' => $tenantId, 'year_month' => $yearMonth],
['amount' => 0, 'memo' => null]
);
}
}

View File

@@ -62,6 +62,11 @@ function CorporateCardsManagement() {
const [modalMode, setModalMode] = useState('add'); // 'add' or 'edit'
const [editingCard, setEditingCard] = useState(null);
// 요약 데이터
const [summaryData, setSummaryData] = useState(null);
const [showPrepaymentModal, setShowPrepaymentModal] = useState(false);
const [prepaymentForm, setPrepaymentForm] = useState({ amount: '', memo: '' });
// CSRF 토큰
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
@@ -90,18 +95,73 @@ function CorporateCardsManagement() {
const fetchCards = async () => {
try {
setLoading(true);
const response = await fetch('/finance/corporate-cards/list');
const result = await response.json();
if (result.success) {
setCards(result.data);
const [cardsRes, summaryRes] = await Promise.all([
fetch('/finance/corporate-cards/list'),
fetch('/finance/corporate-cards/summary'),
]);
const cardsResult = await cardsRes.json();
const summaryResult = await summaryRes.json();
if (cardsResult.success) {
setCards(cardsResult.data);
}
if (summaryResult.success) {
setSummaryData(summaryResult.data);
}
} catch (error) {
console.error('카드 목록 로드 실패:', error);
console.error('데이터 로드 실패:', error);
} finally {
setLoading(false);
}
};
// 선불결제 저장
const handleSavePrepayment = async () => {
try {
const response = await fetch('/finance/corporate-cards/prepayment', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
body: JSON.stringify({
amount: parseInt(parseInputCurrency(prepaymentForm.amount)) || 0,
memo: prepaymentForm.memo,
}),
});
const result = await response.json();
if (result.success) {
setSummaryData(prev => ({
...prev,
prepaidAmount: result.data.amount,
prepaidMemo: result.data.memo,
}));
setShowPrepaymentModal(false);
} else {
alert(result.message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('선불결제 저장 실패:', error);
alert('저장 중 오류가 발생했습니다.');
}
};
// 선불결제 수정 모달 열기
const openPrepaymentModal = () => {
setPrepaymentForm({
amount: summaryData?.prepaidAmount ? String(summaryData.prepaidAmount) : '',
memo: summaryData?.prepaidMemo || '',
});
setShowPrepaymentModal(true);
};
// 날짜 포맷 (M/D(요일))
const formatPaymentDate = (dateStr) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
const days = ['일', '월', '화', '수', '목', '금', '토'];
return `${date.getMonth() + 1}/${date.getDate()}(${days[date.getDay()]})`;
};
// 테스트용 임시 데이터 생성
const generateTestData = async () => {
const companies = ['삼성카드', '현대카드', '국민카드', '신한카드', '롯데카드'];
@@ -377,38 +437,83 @@ className="p-1.5 bg-amber-500 hover:bg-amber-600 text-white rounded transition-c
</header>
{/* 요약 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="grid grid-cols-2 lg:grid-cols-6 gap-4 mb-6">
{/* 1. 등록 카드 */}
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500">등록 카드</span>
<CreditCard className="w-5 h-5 text-gray-400" />
<span className="text-xs text-gray-500">등록 카드</span>
<CreditCard className="w-4 h-4 text-gray-400" />
</div>
<p className="text-2xl font-bold text-gray-900">{cards.length}</p>
<p className="text-xl font-bold text-gray-900">{cards.length}</p>
<p className="text-xs text-gray-400 mt-1">활성 {cards.filter(c => c.status === 'active').length}</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
{/* 2. 총 한도 */}
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500"> 한도</span>
<DollarSign className="w-5 h-5 text-gray-400" />
<span className="text-xs text-gray-500"> 한도</span>
<DollarSign className="w-4 h-4 text-gray-400" />
</div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalLimit)}</p>
<p className="text-xl font-bold text-gray-900">{formatCurrency(totalLimit)}</p>
<p className="text-xs text-gray-400 mt-1">신용카드 기준</p>
</div>
<div className="bg-white rounded-xl border border-violet-200 p-6 bg-violet-50/30">
{/* 3. 매월결제일 */}
<div className="bg-white rounded-xl border border-blue-200 p-4 bg-blue-50/30">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-violet-700">이번 사용</span>
<CreditCard className="w-5 h-5 text-violet-500" />
<span className="text-xs text-blue-700">매월결제일</span>
<Calendar className="w-4 h-4 text-blue-500" />
</div>
<p className="text-2xl font-bold text-violet-600">{formatCurrency(totalUsage)}</p>
<p className="text-xs text-violet-500 mt-1">{totalLimit > 0 ? getUsagePercent(totalUsage, totalLimit) : 0}% 사용</p>
<p className="text-xl font-bold text-blue-600">
{summaryData?.paymentDate ? formatPaymentDate(summaryData.paymentDate) : '-'}
</p>
<p className="text-xs text-blue-400 mt-1">
{summaryData?.isAdjusted
? `${summaryData.paymentDay}일→휴일조정`
: summaryData?.paymentDay ? `매월 ${summaryData.paymentDay}일` : '-'}
</p>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
{/* 4. 사용금액 (청구기간) */}
<div className="bg-white rounded-xl border border-violet-200 p-4 bg-violet-50/30">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500">잔여 한도</span>
<DollarSign className="w-5 h-5 text-emerald-500" />
<span className="text-xs text-violet-700">사용금액</span>
<CreditCard className="w-4 h-4 text-violet-500" />
</div>
<p className="text-2xl font-bold text-emerald-600">{formatCurrency(totalLimit - totalUsage)}</p>
<p className="text-xs text-gray-400 mt-1">사용 가능</p>
<p className="text-xl font-bold text-violet-600">
{summaryData ? formatCurrency(summaryData.billingUsage) : '0'}
</p>
<p className="text-xs text-violet-400 mt-1">
{summaryData?.billingPeriod
? `${summaryData.billingPeriod.start.slice(5).replace('-','/')}~${summaryData.billingPeriod.end.slice(5).replace('-','/')} 기준`
: '-'}
</p>
</div>
{/* 5. 선불결제 */}
<div className="bg-white rounded-xl border border-amber-200 p-4 bg-amber-50/30">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-amber-700">선불결제</span>
<button
onClick={openPrepaymentModal}
className="text-xs px-1.5 py-0.5 bg-amber-100 hover:bg-amber-200 text-amber-700 rounded transition-colors"
>
수정
</button>
</div>
<p className="text-xl font-bold text-amber-600">
{summaryData ? formatCurrency(summaryData.prepaidAmount) : '0'}
</p>
<p className="text-xs text-amber-400 mt-1 truncate" title={summaryData?.prepaidMemo || ''}>
{summaryData?.prepaidMemo || '미입력'}
</p>
</div>
{/* 6. 잔여 한도 */}
<div className="bg-white rounded-xl border border-emerald-200 p-4 bg-emerald-50/30">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-emerald-700">잔여 한도</span>
<DollarSign className="w-4 h-4 text-emerald-500" />
</div>
<p className="text-xl font-bold text-emerald-600">
{formatCurrency(totalLimit - (summaryData?.billingUsage || 0) + (summaryData?.prepaidAmount || 0))}
</p>
<p className="text-xs text-emerald-400 mt-1">한도-사용+선결제</p>
</div>
</div>
@@ -749,6 +854,59 @@ className="flex-1 px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded
</div>
</div>
)}
{/* 선불결제 수정 모달 */}
{showPrepaymentModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-sm mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900">선불결제 금액 수정</h3>
<button
onClick={() => setShowPrepaymentModal(false)}
className="p-1 hover:bg-gray-100 rounded-lg"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">선불결제 금액</label>
<input
type="text"
value={formatInputCurrency(prepaymentForm.amount)}
onChange={(e) => setPrepaymentForm(prev => ({ ...prev, amount: parseInputCurrency(e.target.value) }))}
placeholder="0"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500 text-right text-lg font-bold"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">메모 (선택)</label>
<input
type="text"
value={prepaymentForm.memo}
onChange={(e) => setPrepaymentForm(prev => ({ ...prev, memo: e.target.value }))}
placeholder="예: 2월 선결제"
maxLength={200}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500"
/>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => setShowPrepaymentModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
취소
</button>
<button
onClick={handleSavePrepayment}
className="flex-1 px-4 py-2 bg-amber-500 hover:bg-amber-600 text-white rounded-lg"
>
저장
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -886,6 +886,8 @@
// 법인카드 API
Route::prefix('corporate-cards')->name('corporate-cards.')->group(function () {
Route::get('/list', [\App\Http\Controllers\Finance\CorporateCardController::class, 'index'])->name('list');
Route::get('/summary', [\App\Http\Controllers\Finance\CorporateCardController::class, 'summary'])->name('summary');
Route::post('/prepayment', [\App\Http\Controllers\Finance\CorporateCardController::class, 'updatePrepayment'])->name('prepayment');
Route::post('/store', [\App\Http\Controllers\Finance\CorporateCardController::class, 'store'])->name('store');
Route::put('/{id}', [\App\Http\Controllers\Finance\CorporateCardController::class, 'update'])->name('update');
Route::post('/{id}/deactivate', [\App\Http\Controllers\Finance\CorporateCardController::class, 'deactivate'])->name('deactivate');