diff --git a/app/Http/Controllers/Api/V1/BarobillBillingController.php b/app/Http/Controllers/Api/V1/BarobillBillingController.php new file mode 100644 index 00000000..855219b9 --- /dev/null +++ b/app/Http/Controllers/Api/V1/BarobillBillingController.php @@ -0,0 +1,172 @@ +validate([ + 'member_id' => 'nullable|integer', + ]); + + return ApiResponse::handle(function () use ($data) { + return [ + 'subscriptions' => $this->billingService->getSubscriptions($data['member_id'] ?? null), + ]; + }, __('message.fetched')); + } + + /** + * 구독 등록/수정 + */ + public function saveSubscription(Request $request) + { + $data = $request->validate([ + 'member_id' => 'required|integer|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', + ]); + + return ApiResponse::handle(function () use ($data) { + return $this->billingService->saveSubscription( + $data['member_id'], + $data['service_type'], + $data + ); + }, __('message.created')); + } + + /** + * 구독 해지 + */ + public function cancelSubscription(int $id) + { + return ApiResponse::handle(function () use ($id) { + $this->billingService->cancelSubscription($id); + + return ['cancelled' => true]; + }, __('message.deleted')); + } + + /** + * 월별 과금 현황 목록 + */ + public function billingList(Request $request) + { + $data = $request->validate([ + 'billing_month' => 'nullable|string|date_format:Y-m', + 'tenant_id' => 'nullable|integer', + ]); + + return ApiResponse::handle(function () use ($data) { + $billingMonth = $data['billing_month'] ?? Carbon::now()->format('Y-m'); + $tenantId = $data['tenant_id'] ?? null; + + return [ + 'list' => $this->billingService->getMonthlyBillingList($billingMonth, $tenantId), + 'total' => $this->billingService->getMonthlyTotal($billingMonth, $tenantId), + 'billing_month' => $billingMonth, + ]; + }, __('message.fetched')); + } + + /** + * 월별 과금 통계 + */ + public function billingStats(Request $request) + { + $data = $request->validate([ + 'billing_month' => 'nullable|string|date_format:Y-m', + 'tenant_id' => 'nullable|integer', + ]); + + return ApiResponse::handle(function () use ($data) { + $billingMonth = $data['billing_month'] ?? Carbon::now()->format('Y-m'); + + return $this->billingService->getMonthlyTotal($billingMonth, $data['tenant_id'] ?? null); + }, __('message.fetched')); + } + + /** + * 월별 과금 배치 처리 + */ + public function processBilling(Request $request) + { + $data = $request->validate([ + 'billing_month' => 'nullable|string|date_format:Y-m', + ]); + + return ApiResponse::handle(function () use ($data) { + return $this->billingService->processMonthlyBilling($data['billing_month'] ?? null); + }, __('message.created')); + } + + /** + * 연간 과금 추이 + */ + public function yearlyTrend(Request $request) + { + $data = $request->validate([ + 'year' => 'nullable|integer|min:2020|max:2030', + 'tenant_id' => 'nullable|integer', + ]); + + return ApiResponse::handle(function () use ($data) { + $year = $data['year'] ?? Carbon::now()->year; + + return [ + 'trend' => $this->billingService->getYearlyTrend($year, $data['tenant_id'] ?? null), + 'year' => $year, + ]; + }, __('message.fetched')); + } + + /** + * 과금 정책 목록 + */ + public function pricingPolicies() + { + return ApiResponse::handle(function () { + return ['policies' => $this->billingService->getPricingPolicies()]; + }, __('message.fetched')); + } + + /** + * 과금 정책 수정 + */ + public function updatePricingPolicy(Request $request, int $id) + { + $data = $request->validate([ + 'name' => 'nullable|string|max:100', + 'description' => 'nullable|string|max:500', + 'free_quota' => 'nullable|integer|min:0', + 'free_quota_unit' => 'nullable|string|max:10', + 'additional_unit' => 'nullable|integer|min:1', + 'additional_unit_label' => 'nullable|string|max:10', + 'additional_price' => 'nullable|integer|min:0', + 'is_active' => 'nullable|boolean', + ]); + + return ApiResponse::handle(function () use ($id, $data) { + return $this->billingService->updatePricingPolicy($id, $data); + }, __('message.updated')); + } +} diff --git a/app/Http/Controllers/Api/V1/BarobillUsageController.php b/app/Http/Controllers/Api/V1/BarobillUsageController.php new file mode 100644 index 00000000..107e298c --- /dev/null +++ b/app/Http/Controllers/Api/V1/BarobillUsageController.php @@ -0,0 +1,96 @@ +validate([ + 'start_date' => 'nullable|date_format:Y-m-d', + 'end_date' => 'nullable|date_format:Y-m-d', + 'tenant_id' => 'nullable|integer', + ]); + + return ApiResponse::handle(function () use ($data) { + $startDate = $data['start_date'] ?? Carbon::now()->startOfMonth()->format('Y-m-d'); + $endDate = $data['end_date'] ?? Carbon::now()->format('Y-m-d'); + $tenantId = $data['tenant_id'] ?? null; + + $usageList = $this->usageService->getUsageList($startDate, $endDate, $tenantId); + + return [ + 'data' => $usageList, + 'stats' => $this->usageService->aggregateStats($usageList), + 'meta' => [ + 'start_date' => $startDate, + 'end_date' => $endDate, + ], + ]; + }, __('message.fetched')); + } + + /** + * 사용량 통계 + */ + public function stats(Request $request) + { + $data = $request->validate([ + 'start_date' => 'nullable|date_format:Y-m-d', + 'end_date' => 'nullable|date_format:Y-m-d', + 'tenant_id' => 'nullable|integer', + ]); + + return ApiResponse::handle(function () use ($data) { + $startDate = $data['start_date'] ?? Carbon::now()->startOfMonth()->format('Y-m-d'); + $endDate = $data['end_date'] ?? Carbon::now()->format('Y-m-d'); + + $usageList = $this->usageService->getUsageList($startDate, $endDate, $data['tenant_id'] ?? null); + + return $this->usageService->aggregateStats($usageList); + }, __('message.fetched')); + } + + /** + * 회원별 사용량 상세 + */ + public function show(Request $request, int $memberId) + { + $data = $request->validate([ + 'start_date' => 'nullable|date_format:Y-m-d', + 'end_date' => 'nullable|date_format:Y-m-d', + ]); + + return ApiResponse::handle(function () use ($memberId, $data) { + $member = BarobillMember::withoutGlobalScopes()->findOrFail($memberId); + $startDate = $data['start_date'] ?? Carbon::now()->startOfMonth()->format('Y-m-d'); + $endDate = $data['end_date'] ?? Carbon::now()->format('Y-m-d'); + + return $this->usageService->getMemberUsage($member, $startDate, $endDate); + }, __('message.fetched')); + } + + /** + * 과금 정책 정보 + */ + public function priceInfo() + { + return ApiResponse::handle(function () { + return ['prices' => BarobillUsageService::getPriceInfo()]; + }, __('message.fetched')); + } +} diff --git a/app/Services/Barobill/BarobillBillingService.php b/app/Services/Barobill/BarobillBillingService.php new file mode 100644 index 00000000..4aaecd4d --- /dev/null +++ b/app/Services/Barobill/BarobillBillingService.php @@ -0,0 +1,305 @@ +with('member'); + + if ($memberId) { + $query->where('member_id', $memberId); + } + + return $query->orderBy('created_at', 'desc')->get()->toArray(); + } + + /** + * 구독 등록/수정 + */ + public function saveSubscription(int $memberId, string $serviceType, array $data): BarobillSubscription + { + return BarobillSubscription::withoutGlobalScopes()->updateOrCreate( + [ + 'member_id' => $memberId, + 'service_type' => $serviceType, + ], + [ + 'monthly_fee' => $data['monthly_fee'] ?? BarobillSubscription::DEFAULT_MONTHLY_FEES[$serviceType] ?? 0, + 'started_at' => $data['started_at'] ?? now(), + 'ended_at' => $data['ended_at'] ?? null, + 'is_active' => $data['is_active'] ?? true, + 'memo' => $data['memo'] ?? null, + ] + ); + } + + /** + * 구독 해지 + */ + public function cancelSubscription(int $subscriptionId): bool + { + $subscription = BarobillSubscription::withoutGlobalScopes()->findOrFail($subscriptionId); + $subscription->update([ + 'ended_at' => now(), + 'is_active' => false, + ]); + + return true; + } + + /** + * 월정액 과금 배치 처리 + */ + public function processMonthlyBilling(?string $billingMonth = null): array + { + $billingMonth = $billingMonth ?: Carbon::now()->format('Y-m'); + + $subscriptions = BarobillSubscription::withoutGlobalScopes() + ->where('is_active', true) + ->get(); + + $processed = 0; + $skipped = 0; + $errors = 0; + + foreach ($subscriptions as $subscription) { + $exists = BarobillBillingRecord::withoutGlobalScopes() + ->where('member_id', $subscription->member_id) + ->where('billing_month', $billingMonth) + ->where('service_type', $subscription->service_type) + ->where('billing_type', 'subscription') + ->exists(); + + if ($exists) { + $skipped++; + + continue; + } + + try { + BarobillBillingRecord::withoutGlobalScopes()->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' => now(), + 'description' => $this->getServiceLabel($subscription->service_type).' 월정액', + ]); + $processed++; + } catch (\Throwable $e) { + $errors++; + } + } + + $this->updateMonthlySummaries($billingMonth); + + return [ + 'processed' => $processed, + 'skipped' => $skipped, + 'errors' => $errors, + 'billing_month' => $billingMonth, + ]; + } + + /** + * 건별 사용량 과금 기록 + */ + public function recordUsage(int $memberId, string $serviceType, int $quantity, ?string $billingMonth = null): BarobillBillingRecord + { + $billingMonth = $billingMonth ?: Carbon::now()->format('Y-m'); + + $policy = BarobillPricingPolicy::withoutGlobalScopes() + ->where('service_type', $serviceType) + ->where('is_active', true) + ->first(); + + $totalAmount = 0; + if ($policy) { + $billing = $policy->calculateBilling($quantity); + $totalAmount = $billing['billable_amount'] ?? 0; + } + + $record = BarobillBillingRecord::withoutGlobalScopes()->updateOrCreate( + [ + 'member_id' => $memberId, + 'billing_month' => $billingMonth, + 'service_type' => $serviceType, + 'billing_type' => 'usage', + ], + [ + 'quantity' => $quantity, + 'unit_price' => $totalAmount > 0 ? (int) ceil($totalAmount / max($quantity, 1)) : 0, + 'total_amount' => $totalAmount, + 'billed_at' => now(), + 'description' => $this->getServiceLabel($serviceType).' 사용량 과금', + ] + ); + + $this->updateMonthlySummaries($billingMonth); + + return $record; + } + + /** + * 월별 집계 갱신 + */ + public function updateMonthlySummaries(string $billingMonth): void + { + $records = BarobillBillingRecord::withoutGlobalScopes() + ->where('billing_month', $billingMonth) + ->get() + ->groupBy('member_id'); + + foreach ($records as $memberId => $memberRecords) { + $summary = [ + 'bank_account_fee' => 0, + 'card_fee' => 0, + 'hometax_fee' => 0, + 'subscription_total' => 0, + 'tax_invoice_count' => 0, + 'tax_invoice_amount' => 0, + 'usage_total' => 0, + ]; + + foreach ($memberRecords as $record) { + if ($record->billing_type === 'subscription') { + match ($record->service_type) { + 'bank_account' => $summary['bank_account_fee'] = $record->total_amount, + 'card' => $summary['card_fee'] = $record->total_amount, + 'hometax' => $summary['hometax_fee'] = $record->total_amount, + default => null, + }; + $summary['subscription_total'] += $record->total_amount; + } elseif ($record->billing_type === 'usage') { + if ($record->service_type === 'tax_invoice') { + $summary['tax_invoice_count'] = $record->quantity; + $summary['tax_invoice_amount'] = $record->total_amount; + } + $summary['usage_total'] += $record->total_amount; + } + } + + $summary['grand_total'] = $summary['subscription_total'] + $summary['usage_total']; + + BarobillMonthlySummary::withoutGlobalScopes()->updateOrCreate( + ['member_id' => $memberId, 'billing_month' => $billingMonth], + $summary + ); + } + } + + /** + * 월별 과금 현황 목록 + */ + public function getMonthlyBillingList(string $billingMonth, ?int $tenantId = null): array + { + $query = BarobillMonthlySummary::withoutGlobalScopes() + ->with('member') + ->where('billing_month', $billingMonth); + + if ($tenantId) { + $memberIds = BarobillMember::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->pluck('id'); + $query->whereIn('member_id', $memberIds); + } + + return $query->get()->toArray(); + } + + /** + * 월별 합계 + */ + public function getMonthlyTotal(string $billingMonth, ?int $tenantId = null): array + { + $query = BarobillMonthlySummary::withoutGlobalScopes() + ->where('billing_month', $billingMonth); + + if ($tenantId) { + $memberIds = BarobillMember::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->pluck('id'); + $query->whereIn('member_id', $memberIds); + } + + return [ + 'member_count' => $query->count(), + 'bank_account_fee' => (clone $query)->sum('bank_account_fee'), + 'card_fee' => (clone $query)->sum('card_fee'), + 'hometax_fee' => (clone $query)->sum('hometax_fee'), + 'subscription_total' => (clone $query)->sum('subscription_total'), + 'tax_invoice_count' => (clone $query)->sum('tax_invoice_count'), + 'tax_invoice_amount' => (clone $query)->sum('tax_invoice_amount'), + 'usage_total' => (clone $query)->sum('usage_total'), + 'grand_total' => (clone $query)->sum('grand_total'), + ]; + } + + /** + * 연간 과금 추이 + */ + public function getYearlyTrend(int $year, ?int $tenantId = null): array + { + $trend = []; + for ($month = 1; $month <= 12; $month++) { + $billingMonth = sprintf('%d-%02d', $year, $month); + $total = $this->getMonthlyTotal($billingMonth, $tenantId); + $trend[] = [ + 'billing_month' => $billingMonth, + ...$total, + ]; + } + + return $trend; + } + + /** + * 과금 정책 목록 + */ + public function getPricingPolicies(): array + { + return BarobillPricingPolicy::withoutGlobalScopes() + ->orderBy('sort_order') + ->get() + ->toArray(); + } + + /** + * 과금 정책 수정 + */ + public function updatePricingPolicy(int $id, array $data): BarobillPricingPolicy + { + $policy = BarobillPricingPolicy::withoutGlobalScopes()->findOrFail($id); + $policy->update($data); + + return $policy->fresh(); + } + + private function getServiceLabel(string $serviceType): string + { + return match ($serviceType) { + 'bank_account' => '계좌조회', + 'card' => '법인카드', + 'hometax' => '홈택스', + 'tax_invoice' => '전자세금계산서', + default => $serviceType, + }; + } +} diff --git a/app/Services/Barobill/BarobillUsageService.php b/app/Services/Barobill/BarobillUsageService.php new file mode 100644 index 00000000..8af4c86d --- /dev/null +++ b/app/Services/Barobill/BarobillUsageService.php @@ -0,0 +1,163 @@ +where('status', 'active'); + + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } + + $members = $query->get(); + $usageList = []; + + foreach ($members as $member) { + $usageList[] = $this->getMemberUsage($member, $startDate, $endDate); + } + + return $usageList; + } + + /** + * 단일 회원사 사용량 상세 + */ + public function getMemberUsage(BarobillMember $member, string $startDate, string $endDate): array + { + $taxInvoiceCount = $this->getTaxInvoiceCount($member, $startDate, $endDate); + $bankAccountCount = $this->getBankAccountCount($member); + $cardCount = $this->getCardCount($member); + $hometaxCount = $this->getHometaxCount($member, $startDate, $endDate); + + $taxInvoiceBilling = $this->calculateBillingByPolicy('tax_invoice', $taxInvoiceCount); + + return [ + 'member_id' => $member->id, + 'tenant_id' => $member->tenant_id, + 'biz_no' => $member->biz_no, + 'corp_name' => $member->corp_name, + 'barobill_id' => $member->barobill_id, + 'server_mode' => $member->server_mode ?? 'test', + 'tax_invoice_count' => $taxInvoiceCount, + 'bank_account_count' => $bankAccountCount, + 'card_count' => $cardCount, + 'hometax_count' => $hometaxCount, + 'tax_invoice_billing' => $taxInvoiceBilling, + 'total_amount' => $taxInvoiceBilling['billable_amount'] ?? 0, + ]; + } + + /** + * 사용량 통계 집계 + */ + public function aggregateStats(array $usageList): array + { + $stats = [ + 'total_members' => count($usageList), + 'total_tax_invoice_count' => 0, + 'total_bank_account_count' => 0, + 'total_card_count' => 0, + 'total_hometax_count' => 0, + 'total_amount' => 0, + ]; + + foreach ($usageList as $usage) { + $stats['total_tax_invoice_count'] += $usage['tax_invoice_count']; + $stats['total_bank_account_count'] += $usage['bank_account_count']; + $stats['total_card_count'] += $usage['card_count']; + $stats['total_hometax_count'] += $usage['hometax_count']; + $stats['total_amount'] += $usage['total_amount']; + } + + return $stats; + } + + /** + * 과금 정책 정보 + */ + public static function getPriceInfo(): array + { + $policies = BarobillPricingPolicy::withoutGlobalScopes() + ->where('is_active', true) + ->orderBy('sort_order') + ->get(); + + $info = []; + foreach ($policies as $policy) { + $info[$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, + ]; + } + + return $info; + } + + /** + * 정책 기반 과금액 계산 + */ + public static function calculateBillingByPolicy(string $serviceType, int $usageCount): array + { + $policy = BarobillPricingPolicy::withoutGlobalScopes() + ->where('service_type', $serviceType) + ->where('is_active', true) + ->first(); + + if (! $policy) { + return ['free_count' => $usageCount, 'billable_count' => 0, 'billable_amount' => 0]; + } + + return $policy->calculateBilling($usageCount); + } + + protected function getTaxInvoiceCount(BarobillMember $member, string $startDate, string $endDate): int + { + return HometaxInvoice::withoutGlobalScopes() + ->where('tenant_id', $member->tenant_id) + ->where('invoice_type', 'sales') + ->whereBetween('write_date', [$startDate, $endDate]) + ->count(); + } + + protected function getBankAccountCount(BarobillMember $member): int + { + return BarobillBankTransaction::withoutGlobalScopes() + ->where('tenant_id', $member->tenant_id) + ->distinct('bank_account_num') + ->count('bank_account_num'); + } + + protected function getCardCount(BarobillMember $member): int + { + return BarobillCardTransaction::withoutGlobalScopes() + ->where('tenant_id', $member->tenant_id) + ->distinct('card_num') + ->count('card_num'); + } + + protected function getHometaxCount(BarobillMember $member, string $startDate, string $endDate): int + { + return HometaxInvoice::withoutGlobalScopes() + ->where('tenant_id', $member->tenant_id) + ->whereBetween('write_date', [$startDate, $endDate]) + ->count(); + } +} diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index fd66fe6b..4025d108 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -18,12 +18,14 @@ use App\Http\Controllers\Api\V1\BankAccountController; use App\Http\Controllers\Api\V1\BankTransactionController; use App\Http\Controllers\Api\V1\BarobillBankTransactionController; +use App\Http\Controllers\Api\V1\BarobillBillingController; use App\Http\Controllers\Api\V1\BarobillCardTransactionController; use App\Http\Controllers\Api\V1\BarobillController; use App\Http\Controllers\Api\V1\BarobillKakaotalkController; use App\Http\Controllers\Api\V1\BarobillSettingController; use App\Http\Controllers\Api\V1\BarobillSmsController; use App\Http\Controllers\Api\V1\BarobillSyncController; +use App\Http\Controllers\Api\V1\BarobillUsageController; use App\Http\Controllers\Api\V1\BillController; use App\Http\Controllers\Api\V1\CalendarController; use App\Http\Controllers\Api\V1\CardController; @@ -423,6 +425,27 @@ Route::get('/send-state/{sendKey}', [BarobillSmsController::class, 'sendState'])->name('v1.barobill.sms.send-state'); }); +// Barobill Billing API (바로빌 과금 관리) +Route::prefix('barobill/billing')->group(function () { + Route::get('/subscriptions', [BarobillBillingController::class, 'subscriptions'])->name('v1.barobill.billing.subscriptions'); + Route::post('/subscriptions', [BarobillBillingController::class, 'saveSubscription'])->name('v1.barobill.billing.subscriptions.store'); + Route::delete('/subscriptions/{id}', [BarobillBillingController::class, 'cancelSubscription'])->whereNumber('id')->name('v1.barobill.billing.subscriptions.cancel'); + Route::get('/list', [BarobillBillingController::class, 'billingList'])->name('v1.barobill.billing.list'); + Route::get('/stats', [BarobillBillingController::class, 'billingStats'])->name('v1.barobill.billing.stats'); + Route::post('/process', [BarobillBillingController::class, 'processBilling'])->name('v1.barobill.billing.process'); + Route::get('/yearly-trend', [BarobillBillingController::class, 'yearlyTrend'])->name('v1.barobill.billing.yearly-trend'); + Route::get('/pricing-policies', [BarobillBillingController::class, 'pricingPolicies'])->name('v1.barobill.billing.pricing-policies'); + Route::put('/pricing-policies/{id}', [BarobillBillingController::class, 'updatePricingPolicy'])->whereNumber('id')->name('v1.barobill.billing.pricing-policies.update'); +}); + +// Barobill Usage API (바로빌 사용량 조회) +Route::prefix('barobill/usage')->group(function () { + Route::get('', [BarobillUsageController::class, 'index'])->name('v1.barobill.usage.index'); + Route::get('/stats', [BarobillUsageController::class, 'stats'])->name('v1.barobill.usage.stats'); + Route::get('/price-info', [BarobillUsageController::class, 'priceInfo'])->name('v1.barobill.usage.price-info'); + Route::get('/{memberId}', [BarobillUsageController::class, 'show'])->whereNumber('memberId')->name('v1.barobill.usage.show'); +}); + // Bad Debt API (악성채권 추심관리) Route::prefix('bad-debts')->group(function () { Route::get('', [BadDebtController::class, 'index'])->name('v1.bad-debts.index');