From 39161d1203630fb15edffdce3c1959b93770ca1a Mon Sep 17 00:00:00 2001 From: pro Date: Tue, 27 Jan 2026 15:03:44 +0900 Subject: [PATCH] =?UTF-8?q?feat:=EB=B0=94=EB=A1=9C=EB=B9=8C=20=EA=B3=BC?= =?UTF-8?q?=EA=B8=88=EA=B4=80=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모델: BarobillSubscription, BarobillBillingRecord, BarobillMonthlySummary - 서비스: BarobillBillingService (구독/과금 처리 로직) - API 컨트롤러: BarobillBillingController (구독/과금 CRUD) - 뷰: 과금 현황 탭, 구독 관리 탭, 통계 카드, 상세 모달 - 라우트: 웹/API 라우트 추가 Co-Authored-By: Claude Opus 4.5 --- .../Barobill/BarobillBillingController.php | 379 ++++++++++++++++++ .../Barobill/BarobillController.php | 22 + app/Models/Barobill/BarobillBillingRecord.php | 116 ++++++ .../Barobill/BarobillMonthlySummary.php | 141 +++++++ app/Models/Barobill/BarobillSubscription.php | 119 ++++++ .../Barobill/BarobillBillingService.php | 252 ++++++++++++ .../views/barobill/billing/index.blade.php | 354 ++++++++++++++++ .../billing/partials/billing-stats.blade.php | 69 ++++ .../billing/partials/billing-table.blade.php | 156 +++++++ .../partials/member-billing-detail.blade.php | 103 +++++ .../billing/partials/stats-skeleton.blade.php | 11 + .../partials/subscription-table.blade.php | 137 +++++++ routes/api.php | 21 + routes/web.php | 1 + 14 files changed, 1881 insertions(+) create mode 100644 app/Http/Controllers/Api/Admin/Barobill/BarobillBillingController.php create mode 100644 app/Models/Barobill/BarobillBillingRecord.php create mode 100644 app/Models/Barobill/BarobillMonthlySummary.php create mode 100644 app/Models/Barobill/BarobillSubscription.php create mode 100644 app/Services/Barobill/BarobillBillingService.php create mode 100644 resources/views/barobill/billing/index.blade.php create mode 100644 resources/views/barobill/billing/partials/billing-stats.blade.php create mode 100644 resources/views/barobill/billing/partials/billing-table.blade.php create mode 100644 resources/views/barobill/billing/partials/member-billing-detail.blade.php create mode 100644 resources/views/barobill/billing/partials/stats-skeleton.blade.php create mode 100644 resources/views/barobill/billing/partials/subscription-table.blade.php diff --git a/app/Http/Controllers/Api/Admin/Barobill/BarobillBillingController.php b/app/Http/Controllers/Api/Admin/Barobill/BarobillBillingController.php new file mode 100644 index 00000000..ab6822a9 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Barobill/BarobillBillingController.php @@ -0,0 +1,379 @@ +boolean('all_tenants', false); + + $query = BarobillSubscription::with(['member.tenant']) + ->orderBy('member_id') + ->orderBy('service_type'); + + // 테넌트 필터링 + if (!$isHeadquarters && !$allTenants) { + $query->whereHas('member', function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId); + }); + } + + $subscriptions = $query->get(); + + if ($request->header('HX-Request')) { + return response( + view('barobill.billing.partials.subscription-table', [ + 'subscriptions' => $subscriptions, + 'allTenants' => $isHeadquarters || $allTenants, + ])->render(), + 200, + ['Content-Type' => 'text/html'] + ); + } + + return response()->json([ + 'success' => true, + 'data' => $subscriptions, + ]); + } + + /** + * 구독 등록/수정 + */ + public function saveSubscription(Request $request): JsonResponse + { + $validated = $request->validate([ + 'member_id' => 'required|exists:barobill_members,id', + 'service_type' => 'required|in:bank_account,card,hometax', + 'monthly_fee' => 'nullable|integer|min:0', + 'started_at' => 'nullable|date', + 'ended_at' => 'nullable|date|after_or_equal:started_at', + 'is_active' => 'nullable|boolean', + 'memo' => 'nullable|string|max:500', + ]); + + $subscription = $this->billingService->saveSubscription( + $validated['member_id'], + $validated['service_type'], + $validated + ); + + return response()->json([ + 'success' => true, + 'message' => '구독이 저장되었습니다.', + 'data' => $subscription, + ]); + } + + /** + * 구독 해지 + */ + public function cancelSubscription(int $id): JsonResponse + { + $result = $this->billingService->cancelSubscription($id); + + if (!$result) { + return response()->json([ + 'success' => false, + 'message' => '구독을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'message' => '구독이 해지되었습니다.', + ]); + } + + /** + * 회원사별 구독 현황 조회 + */ + public function memberSubscriptions(int $memberId): JsonResponse|Response + { + $member = BarobillMember::with('tenant')->find($memberId); + if (!$member) { + return response()->json([ + 'success' => false, + 'message' => '회원사를 찾을 수 없습니다.', + ], 404); + } + + $subscriptions = BarobillSubscription::where('member_id', $memberId) + ->orderBy('service_type') + ->get(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'member' => $member, + 'subscriptions' => $subscriptions, + ], + ]); + } + + // ======================================== + // 과금 현황 + // ======================================== + + /** + * 월별 과금 현황 목록 + */ + public function billingList(Request $request): JsonResponse|Response + { + $tenantId = session('selected_tenant_id'); + $isHeadquarters = $tenantId == self::HEADQUARTERS_TENANT_ID; + $allTenants = $request->boolean('all_tenants', false); + + $billingMonth = $request->input('billing_month', now()->format('Y-m')); + + // 테넌트 필터링 + $filterTenantId = (!$isHeadquarters && !$allTenants) ? $tenantId : null; + + $summaries = BarobillMonthlySummary::with(['member.tenant']) + ->where('billing_month', $billingMonth) + ->when($filterTenantId, function ($q) use ($filterTenantId) { + $q->whereHas('member', function ($q2) use ($filterTenantId) { + $q2->where('tenant_id', $filterTenantId); + }); + }) + ->orderBy('grand_total', 'desc') + ->get(); + + $total = $this->billingService->getMonthlyTotal($billingMonth, $filterTenantId); + + if ($request->header('HX-Request')) { + return response( + view('barobill.billing.partials.billing-table', [ + 'summaries' => $summaries, + 'total' => $total, + 'billingMonth' => $billingMonth, + 'allTenants' => $isHeadquarters || $allTenants, + ])->render(), + 200, + ['Content-Type' => 'text/html'] + ); + } + + return response()->json([ + 'success' => true, + 'data' => $summaries, + 'total' => $total, + ]); + } + + /** + * 월별 과금 통계 + */ + public function billingStats(Request $request): JsonResponse|Response + { + $tenantId = session('selected_tenant_id'); + $isHeadquarters = $tenantId == self::HEADQUARTERS_TENANT_ID; + $allTenants = $request->boolean('all_tenants', false); + + $billingMonth = $request->input('billing_month', now()->format('Y-m')); + + $filterTenantId = (!$isHeadquarters && !$allTenants) ? $tenantId : null; + $stats = $this->billingService->getMonthlyTotal($billingMonth, $filterTenantId); + + if ($request->header('HX-Request')) { + return response( + view('barobill.billing.partials.billing-stats', compact('stats'))->render(), + 200, + ['Content-Type' => 'text/html'] + ); + } + + return response()->json([ + 'success' => true, + 'data' => $stats, + ]); + } + + /** + * 회원사별 과금 상세 + */ + public function memberBilling(Request $request, int $memberId): JsonResponse|Response + { + $member = BarobillMember::with('tenant')->find($memberId); + if (!$member) { + return response()->json([ + 'success' => false, + 'message' => '회원사를 찾을 수 없습니다.', + ], 404); + } + + $billingMonth = $request->input('billing_month', now()->format('Y-m')); + + $records = BarobillBillingRecord::where('member_id', $memberId) + ->where('billing_month', $billingMonth) + ->orderBy('service_type') + ->get(); + + $summary = BarobillMonthlySummary::where('member_id', $memberId) + ->where('billing_month', $billingMonth) + ->first(); + + if ($request->header('HX-Request')) { + return response( + view('barobill.billing.partials.member-billing-detail', [ + 'member' => $member, + 'records' => $records, + 'summary' => $summary, + 'billingMonth' => $billingMonth, + ])->render(), + 200, + ['Content-Type' => 'text/html'] + ); + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'member' => $member, + 'records' => $records, + 'summary' => $summary, + ], + ]); + } + + /** + * 월별 과금 처리 (수동 실행) + */ + public function processBilling(Request $request): JsonResponse + { + $billingMonth = $request->input('billing_month', now()->format('Y-m')); + + $result = $this->billingService->processMonthlyBilling($billingMonth); + + return response()->json([ + 'success' => true, + 'message' => "과금 처리 완료: {$result['processed']}건 처리, {$result['skipped']}건 스킵", + 'data' => $result, + ]); + } + + /** + * 연간 추이 조회 + */ + public function yearlyTrend(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id'); + $isHeadquarters = $tenantId == self::HEADQUARTERS_TENANT_ID; + $allTenants = $request->boolean('all_tenants', false); + + $year = $request->input('year', now()->year); + + $filterTenantId = (!$isHeadquarters && !$allTenants) ? $tenantId : null; + $trend = $this->billingService->getYearlyTrend($year, $filterTenantId); + + return response()->json([ + 'success' => true, + 'data' => $trend, + ]); + } + + /** + * 엑셀 다운로드 + */ + public function export(Request $request) + { + $tenantId = session('selected_tenant_id'); + $isHeadquarters = $tenantId == self::HEADQUARTERS_TENANT_ID; + $allTenants = $request->boolean('all_tenants', false); + + $billingMonth = $request->input('billing_month', now()->format('Y-m')); + + $filterTenantId = (!$isHeadquarters && !$allTenants) ? $tenantId : null; + + $summaries = BarobillMonthlySummary::with(['member.tenant']) + ->where('billing_month', $billingMonth) + ->when($filterTenantId, function ($q) use ($filterTenantId) { + $q->whereHas('member', function ($q2) use ($filterTenantId) { + $q2->where('tenant_id', $filterTenantId); + }); + }) + ->orderBy('grand_total', 'desc') + ->get(); + + $total = $this->billingService->getMonthlyTotal($billingMonth, $filterTenantId); + + $filename = "barobill_billing_{$billingMonth}.csv"; + + $headers = [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => "attachment; filename=\"{$filename}\"", + ]; + + $callback = function () use ($summaries, $total, $isHeadquarters, $allTenants) { + $file = fopen('php://output', 'w'); + fprintf($file, chr(0xEF) . chr(0xBB) . chr(0xBF)); + + // 헤더 + $headerRow = ['사업자번호', '상호', '계좌조회', '카드내역', '홈텍스', '월정액합계', '세금계산서(건)', '세금계산서(원)', '건별합계', '총합계']; + if ($isHeadquarters || $allTenants) { + array_unshift($headerRow, 'T-ID', '테넌트'); + } + fputcsv($file, $headerRow); + + // 데이터 + foreach ($summaries as $summary) { + $row = [ + $summary->member->formatted_biz_no ?? '', + $summary->member->corp_name ?? '', + $summary->bank_account_fee, + $summary->card_fee, + $summary->hometax_fee, + $summary->subscription_total, + $summary->tax_invoice_count, + $summary->tax_invoice_amount, + $summary->usage_total, + $summary->grand_total, + ]; + if ($isHeadquarters || $allTenants) { + array_unshift($row, $summary->member->tenant_id ?? '', $summary->member->tenant->company_name ?? ''); + } + fputcsv($file, $row); + } + + // 합계 + $totalRow = ['', '합계', $total['bank_account_fee'], $total['card_fee'], $total['hometax_fee'], $total['subscription_total'], $total['tax_invoice_count'], $total['tax_invoice_amount'], $total['usage_total'], $total['grand_total']]; + if ($isHeadquarters || $allTenants) { + array_unshift($totalRow, '', ''); + } + fputcsv($file, $totalRow); + + fclose($file); + }; + + return response()->stream($callback, 200, $headers); + } +} diff --git a/app/Http/Controllers/Barobill/BarobillController.php b/app/Http/Controllers/Barobill/BarobillController.php index ae04b9f4..acae8706 100644 --- a/app/Http/Controllers/Barobill/BarobillController.php +++ b/app/Http/Controllers/Barobill/BarobillController.php @@ -140,4 +140,26 @@ public function usage(Request $request): View|Response return view('barobill.usage.index', compact('currentTenant', 'barobillMember', 'isHeadquarters')); } + + /** + * 과금관리 페이지 (본사 전용) + */ + public function billing(Request $request): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('barobill.billing.index')); + } + + // 현재 선택된 테넌트 정보 + $tenantId = session('selected_tenant_id', 1); + $currentTenant = Tenant::find($tenantId); + + // 본사(테넌트 1) 여부 + $isHeadquarters = $tenantId == 1; + + // 테넌트 목록 (전체 테넌트 모드에서 선택용) + $tenants = Tenant::select('id', 'company_name')->orderBy('company_name')->get(); + + return view('barobill.billing.index', compact('currentTenant', 'isHeadquarters', 'tenants')); + } } diff --git a/app/Models/Barobill/BarobillBillingRecord.php b/app/Models/Barobill/BarobillBillingRecord.php new file mode 100644 index 00000000..2584ebe8 --- /dev/null +++ b/app/Models/Barobill/BarobillBillingRecord.php @@ -0,0 +1,116 @@ + 'integer', + 'unit_price' => 'integer', + 'total_amount' => 'integer', + 'billed_at' => 'date', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * 서비스 유형 라벨 + */ + public const SERVICE_TYPES = [ + 'tax_invoice' => '전자세금계산서', + 'bank_account' => '계좌조회', + 'card' => '카드내역', + 'hometax' => '홈텍스 매입/매출', + ]; + + /** + * 과금 유형 라벨 + */ + public const BILLING_TYPES = [ + 'subscription' => '월정액', + 'usage' => '건별', + ]; + + /** + * 건별 단가 (원) + */ + public const USAGE_UNIT_PRICES = [ + 'tax_invoice' => 100, // 전자세금계산서 건당 100원 + ]; + + /** + * 바로빌 회원사 관계 + */ + public function member(): BelongsTo + { + return $this->belongsTo(BarobillMember::class, 'member_id'); + } + + /** + * 서비스 유형 라벨 + */ + public function getServiceTypeLabelAttribute(): string + { + return self::SERVICE_TYPES[$this->service_type] ?? $this->service_type; + } + + /** + * 과금 유형 라벨 + */ + public function getBillingTypeLabelAttribute(): string + { + return self::BILLING_TYPES[$this->billing_type] ?? $this->billing_type; + } + + /** + * 특정 월 조회 + */ + public function scopeOfMonth($query, string $billingMonth) + { + return $query->where('billing_month', $billingMonth); + } + + /** + * 월정액만 조회 + */ + public function scopeSubscription($query) + { + return $query->where('billing_type', 'subscription'); + } + + /** + * 건별만 조회 + */ + public function scopeUsage($query) + { + return $query->where('billing_type', 'usage'); + } + + /** + * 특정 서비스 조회 + */ + public function scopeOfService($query, string $serviceType) + { + return $query->where('service_type', $serviceType); + } +} diff --git a/app/Models/Barobill/BarobillMonthlySummary.php b/app/Models/Barobill/BarobillMonthlySummary.php new file mode 100644 index 00000000..f5cdabe7 --- /dev/null +++ b/app/Models/Barobill/BarobillMonthlySummary.php @@ -0,0 +1,141 @@ + 'integer', + 'card_fee' => 'integer', + 'hometax_fee' => 'integer', + 'subscription_total' => 'integer', + 'tax_invoice_count' => 'integer', + 'tax_invoice_amount' => 'integer', + 'usage_total' => 'integer', + 'grand_total' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * 바로빌 회원사 관계 + */ + public function member(): BelongsTo + { + return $this->belongsTo(BarobillMember::class, 'member_id'); + } + + /** + * 특정 월 조회 + */ + public function scopeOfMonth($query, string $billingMonth) + { + return $query->where('billing_month', $billingMonth); + } + + /** + * 집계 데이터 갱신 또는 생성 + */ + public static function updateOrCreateSummary(int $memberId, string $billingMonth): self + { + // 해당 월의 과금 기록 조회 + $records = BarobillBillingRecord::where('member_id', $memberId) + ->where('billing_month', $billingMonth) + ->get(); + + $data = [ + 'bank_account_fee' => 0, + 'card_fee' => 0, + 'hometax_fee' => 0, + 'subscription_total' => 0, + 'tax_invoice_count' => 0, + 'tax_invoice_amount' => 0, + 'usage_total' => 0, + 'grand_total' => 0, + ]; + + foreach ($records as $record) { + if ($record->billing_type === 'subscription') { + switch ($record->service_type) { + case 'bank_account': + $data['bank_account_fee'] = $record->total_amount; + break; + case 'card': + $data['card_fee'] = $record->total_amount; + break; + case 'hometax': + $data['hometax_fee'] = $record->total_amount; + break; + } + $data['subscription_total'] += $record->total_amount; + } else { + // 건별 사용 + if ($record->service_type === 'tax_invoice') { + $data['tax_invoice_count'] = $record->quantity; + $data['tax_invoice_amount'] = $record->total_amount; + } + $data['usage_total'] += $record->total_amount; + } + } + + $data['grand_total'] = $data['subscription_total'] + $data['usage_total']; + + return self::updateOrCreate( + ['member_id' => $memberId, 'billing_month' => $billingMonth], + $data + ); + } + + /** + * 전체 회원사 월별 합계 + */ + public static function getMonthlyTotal(string $billingMonth): array + { + $result = self::where('billing_month', $billingMonth) + ->selectRaw(' + COUNT(*) as member_count, + SUM(bank_account_fee) as bank_account_fee, + SUM(card_fee) as card_fee, + SUM(hometax_fee) as hometax_fee, + SUM(subscription_total) as subscription_total, + SUM(tax_invoice_count) as tax_invoice_count, + SUM(tax_invoice_amount) as tax_invoice_amount, + SUM(usage_total) as usage_total, + SUM(grand_total) as grand_total + ') + ->first(); + + return [ + 'member_count' => (int) ($result->member_count ?? 0), + 'bank_account_fee' => (int) ($result->bank_account_fee ?? 0), + 'card_fee' => (int) ($result->card_fee ?? 0), + 'hometax_fee' => (int) ($result->hometax_fee ?? 0), + 'subscription_total' => (int) ($result->subscription_total ?? 0), + 'tax_invoice_count' => (int) ($result->tax_invoice_count ?? 0), + 'tax_invoice_amount' => (int) ($result->tax_invoice_amount ?? 0), + 'usage_total' => (int) ($result->usage_total ?? 0), + 'grand_total' => (int) ($result->grand_total ?? 0), + ]; + } +} diff --git a/app/Models/Barobill/BarobillSubscription.php b/app/Models/Barobill/BarobillSubscription.php new file mode 100644 index 00000000..4bfcae47 --- /dev/null +++ b/app/Models/Barobill/BarobillSubscription.php @@ -0,0 +1,119 @@ + 'integer', + 'started_at' => 'date', + 'ended_at' => 'date', + 'is_active' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + /** + * 서비스 유형 라벨 + */ + public const SERVICE_TYPES = [ + 'bank_account' => '계좌조회', + 'card' => '카드내역', + 'hometax' => '홈텍스 매입/매출', + ]; + + /** + * 기본 월정액 (원) + */ + public const DEFAULT_MONTHLY_FEES = [ + 'bank_account' => 10000, + 'card' => 10000, + 'hometax' => 10000, + ]; + + /** + * 바로빌 회원사 관계 + */ + public function member(): BelongsTo + { + return $this->belongsTo(BarobillMember::class, 'member_id'); + } + + /** + * 서비스 유형 라벨 + */ + public function getServiceTypeLabelAttribute(): string + { + return self::SERVICE_TYPES[$this->service_type] ?? $this->service_type; + } + + /** + * 상태 라벨 + */ + public function getStatusLabelAttribute(): string + { + if (!$this->is_active) { + return '비활성'; + } + if ($this->ended_at && $this->ended_at->isPast()) { + return '종료'; + } + return '구독중'; + } + + /** + * 상태 색상 클래스 + */ + public function getStatusColorAttribute(): string + { + if (!$this->is_active) { + return 'bg-gray-100 text-gray-800'; + } + if ($this->ended_at && $this->ended_at->isPast()) { + return 'bg-red-100 text-red-800'; + } + return 'bg-green-100 text-green-800'; + } + + /** + * 활성 구독만 조회 + */ + public function scopeActive($query) + { + return $query->where('is_active', true) + ->where(function ($q) { + $q->whereNull('ended_at') + ->orWhere('ended_at', '>=', now()->toDateString()); + }); + } + + /** + * 특정 서비스 유형 조회 + */ + public function scopeOfService($query, string $serviceType) + { + return $query->where('service_type', $serviceType); + } +} diff --git a/app/Services/Barobill/BarobillBillingService.php b/app/Services/Barobill/BarobillBillingService.php new file mode 100644 index 00000000..f139503b --- /dev/null +++ b/app/Services/Barobill/BarobillBillingService.php @@ -0,0 +1,252 @@ +orderBy('service_type') + ->get() + ->toArray(); + } + + /** + * 구독 등록/수정 + */ + public function saveSubscription(int $memberId, string $serviceType, array $data): BarobillSubscription + { + return BarobillSubscription::updateOrCreate( + [ + 'member_id' => $memberId, + 'service_type' => $serviceType, + ], + [ + 'monthly_fee' => $data['monthly_fee'] ?? BarobillSubscription::DEFAULT_MONTHLY_FEES[$serviceType] ?? 0, + 'started_at' => $data['started_at'] ?? now()->toDateString(), + 'ended_at' => $data['ended_at'] ?? null, + 'is_active' => $data['is_active'] ?? true, + 'memo' => $data['memo'] ?? null, + ] + ); + } + + /** + * 구독 해지 + */ + public function cancelSubscription(int $subscriptionId): bool + { + $subscription = BarobillSubscription::find($subscriptionId); + if (!$subscription) { + return false; + } + + $subscription->update([ + 'ended_at' => now()->toDateString(), + 'is_active' => false, + ]); + + return true; + } + + /** + * 월별 과금 처리 (배치용) + * + * 매월 1일에 실행하여 전월 구독료 과금 + */ + public function processMonthlyBilling(?string $billingMonth = null): array + { + $billingMonth = $billingMonth ?? now()->format('Y-m'); + $billedAt = Carbon::parse($billingMonth . '-01'); + + $results = [ + 'billing_month' => $billingMonth, + 'processed' => 0, + 'skipped' => 0, + 'errors' => [], + ]; + + // 활성 구독 조회 + $subscriptions = BarobillSubscription::active() + ->with('member') + ->get(); + + foreach ($subscriptions as $subscription) { + try { + // 이미 과금된 기록이 있는지 확인 + $exists = BarobillBillingRecord::where('member_id', $subscription->member_id) + ->where('billing_month', $billingMonth) + ->where('service_type', $subscription->service_type) + ->where('billing_type', 'subscription') + ->exists(); + + if ($exists) { + $results['skipped']++; + continue; + } + + // 과금 기록 생성 + BarobillBillingRecord::create([ + 'member_id' => $subscription->member_id, + 'billing_month' => $billingMonth, + 'service_type' => $subscription->service_type, + 'billing_type' => 'subscription', + 'quantity' => 1, + 'unit_price' => $subscription->monthly_fee, + 'total_amount' => $subscription->monthly_fee, + 'billed_at' => $billedAt, + 'description' => BarobillSubscription::SERVICE_TYPES[$subscription->service_type] . ' 월정액', + ]); + + $results['processed']++; + + } catch (\Exception $e) { + $results['errors'][] = [ + 'member_id' => $subscription->member_id, + 'service_type' => $subscription->service_type, + 'error' => $e->getMessage(), + ]; + Log::error('월정액 과금 처리 실패', [ + 'subscription_id' => $subscription->id, + 'error' => $e->getMessage(), + ]); + } + } + + // 월별 집계 갱신 + $this->updateMonthlySummaries($billingMonth); + + return $results; + } + + /** + * 건별 사용량 과금 (세금계산서) + */ + public function recordUsage(int $memberId, string $serviceType, int $quantity, ?string $billingMonth = null): BarobillBillingRecord + { + $billingMonth = $billingMonth ?? now()->format('Y-m'); + $unitPrice = BarobillBillingRecord::USAGE_UNIT_PRICES[$serviceType] ?? 0; + + $record = BarobillBillingRecord::updateOrCreate( + [ + 'member_id' => $memberId, + 'billing_month' => $billingMonth, + 'service_type' => $serviceType, + 'billing_type' => 'usage', + ], + [ + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'total_amount' => $quantity * $unitPrice, + 'billed_at' => now()->toDateString(), + 'description' => BarobillBillingRecord::SERVICE_TYPES[$serviceType] . ' ' . $quantity . '건', + ] + ); + + // 집계 갱신 + BarobillMonthlySummary::updateOrCreateSummary($memberId, $billingMonth); + + return $record; + } + + /** + * 월별 집계 갱신 + */ + public function updateMonthlySummaries(string $billingMonth): void + { + // 해당 월에 과금 기록이 있는 회원사 조회 + $memberIds = BarobillBillingRecord::where('billing_month', $billingMonth) + ->distinct() + ->pluck('member_id'); + + foreach ($memberIds as $memberId) { + BarobillMonthlySummary::updateOrCreateSummary($memberId, $billingMonth); + } + } + + /** + * 월별 과금 현황 조회 + */ + public function getMonthlyBillingList(string $billingMonth, ?int $tenantId = null): array + { + $query = BarobillMonthlySummary::with(['member.tenant']) + ->where('billing_month', $billingMonth); + + if ($tenantId) { + $query->whereHas('member', function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId); + }); + } + + return $query->orderBy('grand_total', 'desc')->get()->toArray(); + } + + /** + * 월별 합계 조회 + */ + public function getMonthlyTotal(string $billingMonth, ?int $tenantId = null): array + { + $query = BarobillMonthlySummary::where('billing_month', $billingMonth); + + if ($tenantId) { + $query->whereHas('member', function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId); + }); + } + + $result = $query->selectRaw(' + COUNT(*) as member_count, + SUM(bank_account_fee) as bank_account_fee, + SUM(card_fee) as card_fee, + SUM(hometax_fee) as hometax_fee, + SUM(subscription_total) as subscription_total, + SUM(tax_invoice_count) as tax_invoice_count, + SUM(tax_invoice_amount) as tax_invoice_amount, + SUM(usage_total) as usage_total, + SUM(grand_total) as grand_total + ')->first(); + + return [ + 'billing_month' => $billingMonth, + 'member_count' => (int) ($result->member_count ?? 0), + 'bank_account_fee' => (int) ($result->bank_account_fee ?? 0), + 'card_fee' => (int) ($result->card_fee ?? 0), + 'hometax_fee' => (int) ($result->hometax_fee ?? 0), + 'subscription_total' => (int) ($result->subscription_total ?? 0), + 'tax_invoice_count' => (int) ($result->tax_invoice_count ?? 0), + 'tax_invoice_amount' => (int) ($result->tax_invoice_amount ?? 0), + 'usage_total' => (int) ($result->usage_total ?? 0), + 'grand_total' => (int) ($result->grand_total ?? 0), + ]; + } + + /** + * 연간 과금 추이 조회 + */ + public function getYearlyTrend(int $year, ?int $tenantId = null): array + { + $months = []; + for ($m = 1; $m <= 12; $m++) { + $billingMonth = sprintf('%d-%02d', $year, $m); + $months[$billingMonth] = $this->getMonthlyTotal($billingMonth, $tenantId); + } + return $months; + } +} diff --git a/resources/views/barobill/billing/index.blade.php b/resources/views/barobill/billing/index.blade.php new file mode 100644 index 00000000..30637a00 --- /dev/null +++ b/resources/views/barobill/billing/index.blade.php @@ -0,0 +1,354 @@ +@extends('layouts.app') + +@section('title', '과금관리') + +@section('content') + +@if($currentTenant) +
+
+
+
+ + + +
+
+
+ T-ID: {{ $currentTenant->id }} + @if($isHeadquarters ?? false) + 파트너사 + @endif +
+

{{ $currentTenant->company_name }}

+
+
+
+
+

조회 기준월

+

{{ now()->format('Y년 m월') }}

+
+
+

과금 유형

+

월정액 + 건별

+
+
+

결제일

+

매월 1일

+
+
+
+
+@endif + +
+ +
+
+

과금관리

+

바로빌 월정액 구독 및 과금 현황 관리

+
+
+ + +
+
+ + +
+ + +
+ + +
+ @include('barobill.billing.partials.stats-skeleton') +
+ + +
+ +
+ + @if($isHeadquarters ?? false) + + @endif + + +
+ + +
+ + +
+ + +
+ + + +
+
+
+ + +
+
+
+
+
+
+
+ + + +
+ + + + + +
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/barobill/billing/partials/billing-stats.blade.php b/resources/views/barobill/billing/partials/billing-stats.blade.php new file mode 100644 index 00000000..7ce07e7c --- /dev/null +++ b/resources/views/barobill/billing/partials/billing-stats.blade.php @@ -0,0 +1,69 @@ + +
+
+

회원사

+
+ + + +
+
+
{{ number_format($stats['member_count']) }}
+
과금 대상
+
+ + +
+
+

월정액

+
+ + + +
+
+
{{ number_format($stats['subscription_total']) }}
+
계좌+카드+홈텍스
+
+ + +
+
+

세금계산서

+
+ + + +
+
+
{{ number_format($stats['tax_invoice_count']) }}
+
{{ number_format($stats['tax_invoice_amount']) }}원
+
+ + +
+
+

건별 합계

+
+ + + +
+
+
{{ number_format($stats['usage_total']) }}
+
세금계산서 등
+
+ + +
+
+

총 과금액

+
+ + + +
+
+
{{ number_format($stats['grand_total']) }}
+
월정액 + 건별
+
diff --git a/resources/views/barobill/billing/partials/billing-table.blade.php b/resources/views/barobill/billing/partials/billing-table.blade.php new file mode 100644 index 00000000..319c8ab4 --- /dev/null +++ b/resources/views/barobill/billing/partials/billing-table.blade.php @@ -0,0 +1,156 @@ +@if($summaries->isEmpty()) +
+ + + +

과금 내역이 없습니다

+

{{ $billingMonth }} 기준 과금 내역이 없습니다.

+

"과금 처리" 버튼을 클릭하여 월정액을 처리하세요.

+
+@else +
+ + + + @if($allTenants ?? false) + + + @endif + + + + + + + + + + + + + @foreach($summaries as $summary) + + @if($allTenants ?? false) + + + @endif + + + + + + + + + + + @endforeach + + + + + @if($allTenants ?? false) + + @endif + + + + + + + + + + +
+ T-ID + + 테넌트 + + 사업자번호 + + 상호명 + + 계좌조회 + + 카드내역 + + 홈텍스 + + 월정액 + + 세금계산서 + + 총합계 + + 상세 +
+ + {{ $summary->member->tenant_id ?? '-' }} + + + + {{ $summary->member->tenant->company_name ?? '-' }} + + +
{{ $summary->member->formatted_biz_no ?? '-' }}
+
+
{{ $summary->member->corp_name ?? '-' }}
+
+ + + + {{ number_format($summary->card_fee) }} + + + + {{ number_format($summary->hometax_fee) }} + + + + {{ number_format($summary->subscription_total) }} + + + @if($summary->tax_invoice_count > 0) +
+ {{ number_format($summary->tax_invoice_count) }}건 + ({{ number_format($summary->tax_invoice_amount) }}) +
+ @else + - + @endif +
+ + {{ number_format($summary->grand_total) }} + + + +
+ 합계 + + {{ number_format($total['bank_account_fee']) }} + + {{ number_format($total['card_fee']) }} + + {{ number_format($total['hometax_fee']) }} + + {{ number_format($total['subscription_total']) }} + + {{ number_format($total['tax_invoice_amount']) }} + + {{ number_format($total['grand_total']) }}원 +
+
+@endif diff --git a/resources/views/barobill/billing/partials/member-billing-detail.blade.php b/resources/views/barobill/billing/partials/member-billing-detail.blade.php new file mode 100644 index 00000000..7175cf0c --- /dev/null +++ b/resources/views/barobill/billing/partials/member-billing-detail.blade.php @@ -0,0 +1,103 @@ +{{-- 회원사 과금 상세 모달 내용 --}} +
+ +
+

회원사 정보

+
+
+ 상호명 +

{{ $member->corp_name }}

+
+
+ 사업자번호 +

{{ $member->formatted_biz_no }}

+
+
+ 테넌트 +

{{ $member->tenant->company_name ?? '-' }}

+
+
+ 과금 기준월 +

{{ $billingMonth }}

+
+
+
+ + + @if($records->isNotEmpty()) +
+

과금 내역

+ + @foreach($records as $record) + @php + $colors = [ + 'bank_account' => ['bg' => 'bg-green-50', 'border' => 'border-green-100', 'text' => 'text-green-700', 'icon' => 'text-green-600'], + 'card' => ['bg' => 'bg-blue-50', 'border' => 'border-blue-100', 'text' => 'text-blue-700', 'icon' => 'text-blue-600'], + 'hometax' => ['bg' => 'bg-orange-50', 'border' => 'border-orange-100', 'text' => 'text-orange-700', 'icon' => 'text-orange-600'], + 'tax_invoice' => ['bg' => 'bg-purple-50', 'border' => 'border-purple-100', 'text' => 'text-purple-700', 'icon' => 'text-purple-600'], + ]; + $color = $colors[$record->service_type] ?? ['bg' => 'bg-gray-50', 'border' => 'border-gray-100', 'text' => 'text-gray-700', 'icon' => 'text-gray-600']; + @endphp +
+
+
+ @if($record->service_type === 'tax_invoice') + + + + @else + + + + @endif +
+
+

{{ $record->service_type_label }}

+

+ {{ $record->billing_type_label }} + @if($record->billing_type === 'usage') + ({{ number_format($record->quantity) }}건 x {{ number_format($record->unit_price) }}원) + @endif +

+
+
+
+

{{ number_format($record->total_amount) }}원

+

{{ $record->billed_at?->format('Y-m-d') }}

+
+
+ @endforeach +
+ @else +
+

이 기간에 과금 내역이 없습니다.

+
+ @endif + + + @if($summary) +
+
+
+

월정액 합계

+

{{ number_format($summary->subscription_total) }}원

+
+
+

건별 합계

+

{{ number_format($summary->usage_total) }}원

+
+
+
+
+
+ + + +
+ 총 과금액 +
+ {{ number_format($summary->grand_total) }}원 +
+
+ @endif +
diff --git a/resources/views/barobill/billing/partials/stats-skeleton.blade.php b/resources/views/barobill/billing/partials/stats-skeleton.blade.php new file mode 100644 index 00000000..b8f2669a --- /dev/null +++ b/resources/views/barobill/billing/partials/stats-skeleton.blade.php @@ -0,0 +1,11 @@ + +@for($i = 0; $i < 5; $i++) +
+
+
+
+
+
+
+
+@endfor diff --git a/resources/views/barobill/billing/partials/subscription-table.blade.php b/resources/views/barobill/billing/partials/subscription-table.blade.php new file mode 100644 index 00000000..d9076043 --- /dev/null +++ b/resources/views/barobill/billing/partials/subscription-table.blade.php @@ -0,0 +1,137 @@ +@if($subscriptions->isEmpty()) +
+ + + +

등록된 구독이 없습니다

+

회원사에 월정액 서비스 구독을 등록해주세요.

+
+@else +
+ + + + @if($allTenants ?? false) + + + @endif + + + + + + + + + + + @foreach($subscriptions as $subscription) + + @if($allTenants ?? false) + + + @endif + + + + + + + + + @endforeach + +
+ T-ID + + 테넌트 + + 회원사 + + 서비스 + + 월정액 + + 시작일 + + 종료일 + + 상태 + + 관리 +
+ + {{ $subscription->member->tenant_id ?? '-' }} + + + + {{ $subscription->member->tenant->company_name ?? '-' }} + + +
{{ $subscription->member->corp_name ?? '-' }}
+
{{ $subscription->member->formatted_biz_no ?? '' }}
+
+ @php + $serviceColors = [ + 'bank_account' => 'bg-green-100 text-green-800', + 'card' => 'bg-blue-100 text-blue-800', + 'hometax' => 'bg-orange-100 text-orange-800', + ]; + @endphp + + {{ $subscription->service_type_label }} + + + {{ number_format($subscription->monthly_fee) }}원 + + {{ $subscription->started_at?->format('Y-m-d') ?? '-' }} + + {{ $subscription->ended_at?->format('Y-m-d') ?? '-' }} + + + {{ $subscription->status_label }} + + +
+ @if($subscription->is_active && !$subscription->ended_at) + + @endif +
+
+
+@endif + + diff --git a/routes/api.php b/routes/api.php index 291e8fc1..203f51df 100644 --- a/routes/api.php +++ b/routes/api.php @@ -165,6 +165,27 @@ Route::get('/{memberId}', [\App\Http\Controllers\Api\Admin\Barobill\BarobillUsageController::class, 'show'])->name('show'); }); + // 바로빌 과금관리 API + Route::prefix('barobill/billing')->name('barobill.billing.')->group(function () { + // 구독 관리 + Route::get('/subscriptions', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'subscriptions'])->name('subscriptions'); + Route::post('/subscriptions', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'saveSubscription'])->name('subscriptions.save'); + Route::delete('/subscriptions/{id}', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'cancelSubscription'])->name('subscriptions.cancel'); + Route::get('/subscriptions/member/{memberId}', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'memberSubscriptions'])->name('subscriptions.member'); + + // 과금 현황 + Route::get('/list', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'billingList'])->name('list'); + Route::get('/stats', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'billingStats'])->name('stats'); + Route::get('/member/{memberId}', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'memberBilling'])->name('member'); + Route::get('/export', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'export'])->name('export'); + + // 과금 처리 + Route::post('/process', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'processBilling'])->name('process'); + + // 연간 추이 + Route::get('/yearly-trend', [\App\Http\Controllers\Api\Admin\Barobill\BarobillBillingController::class, 'yearlyTrend'])->name('yearly-trend'); + }); + // 테넌트 관리 API Route::prefix('tenants')->name('tenants.')->group(function () { // 고정 경로는 먼저 정의 diff --git a/routes/web.php b/routes/web.php index 97e609af..9688e559 100644 --- a/routes/web.php +++ b/routes/web.php @@ -320,6 +320,7 @@ Route::get('/card-usage', [\App\Http\Controllers\Barobill\BarobillController::class, 'cardUsage'])->name('card-usage.index'); // Route::get('/hometax', ...) - 아래 hometax 그룹으로 이동됨 Route::get('/usage', [\App\Http\Controllers\Barobill\BarobillController::class, 'usage'])->name('usage.index'); + Route::get('/billing', [\App\Http\Controllers\Barobill\BarobillController::class, 'billing'])->name('billing.index'); // 기존 config 라우트 (호환성) Route::get('/config', [\App\Http\Controllers\Barobill\BarobillController::class, 'config'])->name('config.index');