diff --git a/app/Http/Controllers/Api/Admin/Barobill/BarobillBillingController.php b/app/Http/Controllers/Api/Admin/Barobill/BarobillBillingController.php index ab6822a9..70479c97 100644 --- a/app/Http/Controllers/Api/Admin/Barobill/BarobillBillingController.php +++ b/app/Http/Controllers/Api/Admin/Barobill/BarobillBillingController.php @@ -6,6 +6,7 @@ use App\Models\Barobill\BarobillBillingRecord; use App\Models\Barobill\BarobillMember; use App\Models\Barobill\BarobillMonthlySummary; +use App\Models\Barobill\BarobillPricingPolicy; use App\Models\Barobill\BarobillSubscription; use App\Services\Barobill\BarobillBillingService; use Illuminate\Http\JsonResponse; @@ -376,4 +377,83 @@ public function export(Request $request) return response()->stream($callback, 200, $headers); } + + // ======================================== + // 과금 정책 관리 + // ======================================== + + /** + * 과금 정책 목록 조회 + */ + public function pricingPolicies(Request $request): JsonResponse|Response + { + $policies = BarobillPricingPolicy::orderBy('sort_order')->get(); + + if ($request->header('HX-Request')) { + return response( + view('barobill.billing.partials.pricing-policies-table', [ + 'policies' => $policies, + ])->render(), + 200, + ['Content-Type' => 'text/html'] + ); + } + + return response()->json([ + 'success' => true, + 'data' => $policies, + ]); + } + + /** + * 과금 정책 수정 + */ + public function updatePricingPolicy(Request $request, int $id): JsonResponse + { + $policy = BarobillPricingPolicy::find($id); + if (!$policy) { + return response()->json([ + 'success' => false, + 'message' => '정책을 찾을 수 없습니다.', + ], 404); + } + + $validated = $request->validate([ + 'name' => 'nullable|string|max:100', + 'description' => 'nullable|string|max:255', + 'free_quota' => 'nullable|integer|min:0', + 'free_quota_unit' => 'nullable|string|max:20', + 'additional_unit' => 'nullable|integer|min:1', + 'additional_unit_label' => 'nullable|string|max:20', + 'additional_price' => 'nullable|integer|min:0', + 'is_active' => 'nullable|boolean', + ]); + + $policy->update($validated); + + return response()->json([ + 'success' => true, + 'message' => '정책이 수정되었습니다.', + 'data' => $policy->fresh(), + ]); + } + + /** + * 과금 정책 단일 조회 + */ + public function getPricingPolicy(int $id): JsonResponse + { + $policy = BarobillPricingPolicy::find($id); + if (!$policy) { + return response()->json([ + 'success' => false, + 'message' => '정책을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $policy, + ]); + } } diff --git a/app/Models/Barobill/BarobillPricingPolicy.php b/app/Models/Barobill/BarobillPricingPolicy.php new file mode 100644 index 00000000..88164d33 --- /dev/null +++ b/app/Models/Barobill/BarobillPricingPolicy.php @@ -0,0 +1,157 @@ + 'integer', + 'additional_unit' => 'integer', + 'additional_price' => 'integer', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + + /** + * 서비스 유형 상수 + */ + public const TYPE_CARD = 'card'; + public const TYPE_TAX_INVOICE = 'tax_invoice'; + public const TYPE_BANK_ACCOUNT = 'bank_account'; + + /** + * 서비스 유형 라벨 + */ + public static function getServiceTypeLabels(): array + { + return [ + self::TYPE_CARD => '법인카드 등록', + self::TYPE_TAX_INVOICE => '계산서 발행', + self::TYPE_BANK_ACCOUNT => '계좌조회 수집', + ]; + } + + /** + * 서비스 유형 라벨 Accessor + */ + public function getServiceTypeLabelAttribute(): string + { + return self::getServiceTypeLabels()[$this->service_type] ?? $this->service_type; + } + + /** + * 활성화된 정책만 조회 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * 서비스 유형별 정책 조회 + */ + public static function getByServiceType(string $serviceType): ?self + { + return static::where('service_type', $serviceType) + ->where('is_active', true) + ->first(); + } + + /** + * 모든 활성 정책 조회 (캐시) + */ + public static function getAllActive(): \Illuminate\Database\Eloquent\Collection + { + return static::active() + ->orderBy('sort_order') + ->get(); + } + + /** + * 추가 과금액 계산 + * + * @param int $usageCount 사용량 + * @return array ['free_count' => int, 'billable_count' => int, 'billable_amount' => int] + */ + public function calculateBilling(int $usageCount): array + { + // 무료 제공량 초과분 + $excessCount = max(0, $usageCount - $this->free_quota); + + if ($excessCount === 0 || $this->additional_price === 0) { + return [ + 'free_count' => min($usageCount, $this->free_quota), + 'billable_count' => 0, + 'billable_amount' => 0, + ]; + } + + // 추가 과금 단위에 따른 계산 + // 예: 50건 단위면 51건도 100건도 동일하게 1단위 과금 + $billableUnits = ceil($excessCount / $this->additional_unit); + $billableAmount = (int) ($billableUnits * $this->additional_price); + + return [ + 'free_count' => $this->free_quota, + 'billable_count' => $excessCount, + 'billable_amount' => $billableAmount, + ]; + } + + /** + * 정책 설명 문자열 생성 + */ + public function getPolicyDescriptionAttribute(): string + { + $parts = []; + + if ($this->free_quota > 0) { + $parts[] = "기본 {$this->free_quota}{$this->free_quota_unit} 무료"; + } + + if ($this->additional_price > 0) { + $priceFormatted = number_format($this->additional_price); + if ($this->additional_unit > 1) { + $parts[] = "추가 {$this->additional_unit}{$this->additional_unit_label} 단위 {$priceFormatted}원"; + } else { + $parts[] = "추가 1{$this->additional_unit_label}당 {$priceFormatted}원"; + } + } + + return implode(', ', $parts); + } +} diff --git a/app/Services/Barobill/BarobillUsageService.php b/app/Services/Barobill/BarobillUsageService.php index 583817fc..28b73a96 100644 --- a/app/Services/Barobill/BarobillUsageService.php +++ b/app/Services/Barobill/BarobillUsageService.php @@ -3,6 +3,7 @@ namespace App\Services\Barobill; use App\Models\Barobill\BarobillMember; +use App\Models\Barobill\BarobillPricingPolicy; use Illuminate\Support\Facades\Log; use Illuminate\Support\Collection; @@ -12,22 +13,13 @@ * 바로빌 API에는 직접적인 "사용량 집계" API가 없으므로, * 각 서비스별 내역 조회 API를 통해 건수를 집계합니다. * - * 서비스 단가 (바로빌 기준): - * - 전자세금계산서: 건당 100원 - * - 계좌조회: 건당 10원 - * - 카드사용내역: 건당 10원 - * - 홈텍스매입/매출: 건당 10원 + * 과금 정책 (DB 관리): + * - 전자세금계산서: 기본 100건 무료, 추가 50건 단위 5,000원 + * - 계좌조회: 기본 1계좌 무료, 추가 1계좌당 10,000원 + * - 카드등록: 기본 3장 무료, 추가 1장당 5,000원 */ class BarobillUsageService { - /** - * 서비스별 단가 (원) - */ - public const PRICE_TAX_INVOICE = 100; // 전자세금계산서 - public const PRICE_BANK_ACCOUNT = 10; // 계좌조회 - public const PRICE_CARD = 10; // 카드사용내역 - public const PRICE_HOMETAX = 10; // 홈텍스 매입/매출 - protected BarobillService $barobillService; public function __construct(BarobillService $barobillService) @@ -258,31 +250,87 @@ protected function getHometaxCount(BarobillMember $member, string $startDate, st } /** - * 서비스별 단가 정보 반환 + * 서비스별 과금 정책 정보 반환 (DB에서 조회) */ public static function getPriceInfo(): array { - return [ + $policies = BarobillPricingPolicy::active()->orderBy('sort_order')->get(); + + $priceInfo = []; + + foreach ($policies as $policy) { + $priceInfo[$policy->service_type] = [ + 'name' => $policy->name, + 'free_quota' => $policy->free_quota, + 'free_quota_unit' => $policy->free_quota_unit, + 'additional_unit' => $policy->additional_unit, + 'additional_unit_label' => $policy->additional_unit_label, + 'additional_price' => $policy->additional_price, + 'description' => $policy->policy_description, + ]; + } + + // 기본값 설정 (DB에 정책이 없는 경우) + $defaults = [ 'tax_invoice' => [ - 'name' => '전자세금계산서', - 'price' => self::PRICE_TAX_INVOICE, - 'unit' => '건', + 'name' => '계산서 발행', + 'free_quota' => 100, + 'free_quota_unit' => '건', + 'additional_unit' => 50, + 'additional_unit_label' => '건', + 'additional_price' => 5000, + 'description' => '기본 100건 무료, 추가 50건 단위 5,000원', ], 'bank_account' => [ - 'name' => '계좌조회', - 'price' => self::PRICE_BANK_ACCOUNT, - 'unit' => '건', + 'name' => '계좌조회 수집', + 'free_quota' => 1, + 'free_quota_unit' => '개', + 'additional_unit' => 1, + 'additional_unit_label' => '계좌', + 'additional_price' => 10000, + 'description' => '기본 1계좌 무료, 추가 1계좌당 10,000원', ], 'card' => [ - 'name' => '카드사용내역', - 'price' => self::PRICE_CARD, - 'unit' => '건', - ], - 'hometax' => [ - 'name' => '홈텍스 매입/매출', - 'price' => self::PRICE_HOMETAX, - 'unit' => '건', + 'name' => '법인카드 등록', + 'free_quota' => 3, + 'free_quota_unit' => '장', + 'additional_unit' => 1, + 'additional_unit_label' => '장', + 'additional_price' => 5000, + 'description' => '기본 3장 무료, 추가 1장당 5,000원', ], ]; + + // 없는 정책은 기본값으로 채움 + foreach ($defaults as $type => $default) { + if (!isset($priceInfo[$type])) { + $priceInfo[$type] = $default; + } + } + + return $priceInfo; + } + + /** + * 사용량에 따른 과금액 계산 + * + * @param string $serviceType 서비스 유형 + * @param int $usageCount 사용량/등록 수 + * @return array ['free_count' => int, 'billable_count' => int, 'billable_amount' => int] + */ + public static function calculateBillingByPolicy(string $serviceType, int $usageCount): array + { + $policy = BarobillPricingPolicy::getByServiceType($serviceType); + + if (!$policy) { + // 기본 정책이 없으면 과금 없음 + return [ + 'free_count' => $usageCount, + 'billable_count' => 0, + 'billable_amount' => 0, + ]; + } + + return $policy->calculateBilling($usageCount); } } diff --git a/database/seeders/BarobillPricingPolicySeeder.php b/database/seeders/BarobillPricingPolicySeeder.php new file mode 100644 index 00000000..7ba8202f --- /dev/null +++ b/database/seeders/BarobillPricingPolicySeeder.php @@ -0,0 +1,63 @@ + 'card', + 'name' => '법인카드 등록', + 'description' => '법인카드 등록 기본 3장 제공, 추가 시 장당 과금', + 'free_quota' => 3, + 'free_quota_unit' => '장', + 'additional_unit' => 1, + 'additional_unit_label' => '장', + 'additional_price' => 5000, + 'is_active' => true, + 'sort_order' => 1, + ], + [ + 'service_type' => 'tax_invoice', + 'name' => '계산서 발행', + 'description' => '전자세금계산서 발행 기본 100건 제공, 추가 50건 단위 과금', + 'free_quota' => 100, + 'free_quota_unit' => '건', + 'additional_unit' => 50, + 'additional_unit_label' => '건', + 'additional_price' => 5000, + 'is_active' => true, + 'sort_order' => 2, + ], + [ + 'service_type' => 'bank_account', + 'name' => '계좌조회 수집', + 'description' => '주거래 통장 계좌 기본 1개 제공, 추가 계좌당 과금', + 'free_quota' => 1, + 'free_quota_unit' => '개', + 'additional_unit' => 1, + 'additional_unit_label' => '계좌', + 'additional_price' => 10000, + 'is_active' => true, + 'sort_order' => 3, + ], + ]; + + foreach ($policies as $policy) { + BarobillPricingPolicy::updateOrCreate( + ['service_type' => $policy['service_type']], + $policy + ); + } + + $this->command->info('바로빌 과금 정책 시딩 완료!'); + } +} diff --git a/resources/views/barobill/billing/index.blade.php b/resources/views/barobill/billing/index.blade.php index 30637a00..27d2616d 100644 --- a/resources/views/barobill/billing/index.blade.php +++ b/resources/views/barobill/billing/index.blade.php @@ -80,6 +80,11 @@ class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transitio + @if($isHeadquarters ?? false) + + @endif @@ -167,6 +172,21 @@ class="bg-white rounded-lg shadow-sm overflow-hidden flex-1 flex flex-col min-h- + + +@if($isHeadquarters ?? false) + +@endif @@ -195,6 +215,78 @@ class="bg-white rounded-lg shadow-sm overflow-hidden flex-1 flex flex-col min-h- + +@if($isHeadquarters ?? false) + +@endif +
@endsection @@ -208,24 +300,29 @@ class="bg-white rounded-lg shadow-sm overflow-hidden flex-1 flex flex-col min-h- function switchTab(tab) { currentTab = tab; - // 탭 버튼 스타일 - document.getElementById('tab-billing').classList.toggle('border-blue-600', tab === 'billing'); - document.getElementById('tab-billing').classList.toggle('text-blue-600', tab === 'billing'); - document.getElementById('tab-billing').classList.toggle('border-transparent', tab !== 'billing'); - document.getElementById('tab-billing').classList.toggle('text-gray-500', tab !== 'billing'); + const tabs = ['billing', 'subscription', 'policy']; - document.getElementById('tab-subscription').classList.toggle('border-blue-600', tab === 'subscription'); - document.getElementById('tab-subscription').classList.toggle('text-blue-600', tab === 'subscription'); - document.getElementById('tab-subscription').classList.toggle('border-transparent', tab !== 'subscription'); - document.getElementById('tab-subscription').classList.toggle('text-gray-500', tab !== 'subscription'); + tabs.forEach(t => { + const tabBtn = document.getElementById(`tab-${t}`); + const content = document.getElementById(`content-${t}`); - // 컨텐츠 표시/숨김 - document.getElementById('content-billing').classList.toggle('hidden', tab !== 'billing'); - document.getElementById('content-subscription').classList.toggle('hidden', tab !== 'subscription'); + if (tabBtn) { + tabBtn.classList.toggle('border-blue-600', t === tab); + tabBtn.classList.toggle('text-blue-600', t === tab); + tabBtn.classList.toggle('border-transparent', t !== tab); + tabBtn.classList.toggle('text-gray-500', t !== tab); + } - // 구독 탭 전환 시 데이터 로드 + if (content) { + content.classList.toggle('hidden', t !== tab); + } + }); + + // 탭 전환 시 데이터 로드 if (tab === 'subscription') { htmx.trigger(document.body, 'subscriptionUpdated'); + } else if (tab === 'policy') { + htmx.trigger(document.body, 'policyUpdated'); } } @@ -348,7 +445,73 @@ function showToast(message, type = 'success') { // ESC 키 document.addEventListener('keydown', function(e) { - if (e.key === 'Escape') closeDetailModal(); + if (e.key === 'Escape') { + closeDetailModal(); + closePolicyModal(); + } }); + + // ======================================== + // 정책 관리 + // ======================================== + + function editPolicy(id, policy) { + document.getElementById('policyId').value = id; + document.getElementById('policyName').value = policy.name; + document.getElementById('policyFreeQuota').value = policy.free_quota; + document.getElementById('policyFreeQuotaUnit').value = policy.free_quota_unit; + document.getElementById('policyAdditionalUnit').value = policy.additional_unit; + document.getElementById('policyAdditionalUnitLabel').value = policy.additional_unit_label; + document.getElementById('policyAdditionalPrice').value = policy.additional_price; + document.getElementById('policyDescription').value = policy.description || ''; + document.getElementById('policyIsActive').checked = policy.is_active; + + document.getElementById('policyModal').classList.remove('hidden'); + } + + function closePolicyModal() { + const modal = document.getElementById('policyModal'); + if (modal) { + modal.classList.add('hidden'); + } + } + + async function savePolicy(e) { + e.preventDefault(); + + const id = document.getElementById('policyId').value; + const data = { + free_quota: parseInt(document.getElementById('policyFreeQuota').value), + free_quota_unit: document.getElementById('policyFreeQuotaUnit').value, + additional_unit: parseInt(document.getElementById('policyAdditionalUnit').value), + additional_unit_label: document.getElementById('policyAdditionalUnitLabel').value, + additional_price: parseInt(document.getElementById('policyAdditionalPrice').value), + description: document.getElementById('policyDescription').value, + is_active: document.getElementById('policyIsActive').checked, + }; + + try { + const res = await fetch(`/api/admin/barobill/billing/pricing-policies/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + }, + body: JSON.stringify(data), + }); + + const result = await res.json(); + + if (result.success) { + showToast(result.message, 'success'); + closePolicyModal(); + htmx.trigger(document.body, 'policyUpdated'); + } else { + showToast(result.message || '저장 실패', 'error'); + } + } catch (error) { + showToast('오류가 발생했습니다.', 'error'); + } + } @endpush diff --git a/resources/views/barobill/billing/partials/pricing-policies-table.blade.php b/resources/views/barobill/billing/partials/pricing-policies-table.blade.php new file mode 100644 index 00000000..8c790d93 --- /dev/null +++ b/resources/views/barobill/billing/partials/pricing-policies-table.blade.php @@ -0,0 +1,100 @@ +@if($policies->isEmpty()) +
+ + + +

등록된 정책이 없습니다

+

시더를 실행하여 기본 정책을 등록해주세요.

+
+@else +
+ + + + + + + + + + + + @foreach($policies as $policy) + @php + $colors = [ + 'card' => ['bg' => 'bg-blue-100', 'text' => 'text-blue-800'], + 'tax_invoice' => ['bg' => 'bg-purple-100', 'text' => 'text-purple-800'], + 'bank_account' => ['bg' => 'bg-green-100', 'text' => 'text-green-800'], + ]; + $color = $colors[$policy->service_type] ?? ['bg' => 'bg-gray-100', 'text' => 'text-gray-800']; + @endphp + + + + + + + + @endforeach + +
+ 서비스 + + 기본 제공량 + + 추가 과금 + + 상태 + + 관리 +
+
+ + {{ $policy->name }} + +
+ @if($policy->description) +

{{ $policy->description }}

+ @endif +
+ {{ number_format($policy->free_quota) }}{{ $policy->free_quota_unit }} + 무료 + + @if($policy->additional_price > 0) + @if($policy->additional_unit > 1) + {{ number_format($policy->additional_unit) }}{{ $policy->additional_unit_label }} 단위 + @else + 1{{ $policy->additional_unit_label }}당 + @endif + {{ number_format($policy->additional_price) }}원 + @else + - + @endif + + + {{ $policy->is_active ? '활성' : '비활성' }} + + + +
+
+ + +
+

+ + + + 이 정책은 월별 과금 처리 시 적용됩니다. 기본 제공량을 초과한 사용량에 대해 추가 과금이 발생합니다. +

+
+@endif diff --git a/routes/api.php b/routes/api.php index 203f51df..6fd4561f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -184,6 +184,11 @@ // 연간 추이 Route::get('/yearly-trend', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'yearlyTrend'])->name('yearly-trend'); + + // 과금 정책 관리 + Route::get('/pricing-policies', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'pricingPolicies'])->name('pricing-policies'); + Route::get('/pricing-policies/{id}', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'getPricingPolicy'])->name('pricing-policies.show'); + Route::put('/pricing-policies/{id}', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'updatePricingPolicy'])->name('pricing-policies.update'); }); // 테넌트 관리 API