tenantId(); $query = Subscription::query() ->where('tenant_id', $tenantId) ->with(['plan:id,name,code,price,billing_cycle']); // 상태 필터 if (! empty($params['status'])) { $query->where('status', $params['status']); } // 유효한 구독만 if (! empty($params['valid_only']) && $params['valid_only']) { $query->valid(); } // 만료 예정 (N일 이내) if (! empty($params['expiring_within'])) { $query->expiringWithin((int) $params['expiring_within']); } // 날짜 범위 필터 if (! empty($params['start_date'])) { $query->where('started_at', '>=', $params['start_date']); } if (! empty($params['end_date'])) { $query->where('started_at', '<=', $params['end_date']); } // 정렬 $sortBy = $params['sort_by'] ?? 'started_at'; $sortDir = $params['sort_dir'] ?? 'desc'; $query->orderBy($sortBy, $sortDir); $perPage = $params['per_page'] ?? 20; return $query->paginate($perPage); } /** * 현재 활성 구독 */ public function current(): ?Subscription { $tenantId = $this->tenantId(); return Subscription::query() ->where('tenant_id', $tenantId) ->valid() ->with(['plan', 'payments' => function ($q) { $q->completed()->orderBy('paid_at', 'desc')->limit(5); }]) ->orderBy('started_at', 'desc') ->first(); } /** * 구독 상세 */ public function show(int $id): Subscription { $tenantId = $this->tenantId(); return Subscription::query() ->where('tenant_id', $tenantId) ->with([ 'plan', 'payments' => function ($q) { $q->orderBy('paid_at', 'desc'); }, ]) ->findOrFail($id); } // ========================================================================= // 구독 생성/취소 // ========================================================================= /** * 구독 생성 (결제 포함) */ public function store(array $data): Subscription { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // 요금제 확인 $plan = Plan::active()->findOrFail($data['plan_id']); // 이미 활성 구독이 있는지 확인 $existingSubscription = Subscription::query() ->where('tenant_id', $tenantId) ->valid() ->first(); if ($existingSubscription) { throw new BadRequestHttpException(__('error.subscription.already_active')); } return DB::transaction(function () use ($data, $plan, $tenantId, $userId) { // 구독 생성 $subscription = Subscription::create([ 'tenant_id' => $tenantId, 'plan_id' => $plan->id, 'started_at' => $data['started_at'] ?? now(), 'status' => Subscription::STATUS_PENDING, 'created_by' => $userId, 'updated_by' => $userId, ]); // 결제 생성 (무료 요금제가 아닌 경우) if ($plan->price > 0) { $payment = Payment::create([ 'subscription_id' => $subscription->id, 'amount' => $plan->price, 'payment_method' => $data['payment_method'] ?? Payment::METHOD_CARD, 'status' => Payment::STATUS_PENDING, 'created_by' => $userId, 'updated_by' => $userId, ]); // 결제 완료 처리 (실제 PG 연동 시 수정 필요) if (! empty($data['auto_complete']) && $data['auto_complete']) { $payment->complete($data['transaction_id'] ?? null); // 구독 활성화 $subscription->activate(); } } else { // 무료 요금제는 바로 활성화 Payment::create([ 'subscription_id' => $subscription->id, 'amount' => 0, 'payment_method' => Payment::METHOD_FREE, 'status' => Payment::STATUS_COMPLETED, 'paid_at' => now(), 'created_by' => $userId, 'updated_by' => $userId, ]); $subscription->activate(); } return $subscription->fresh(['plan', 'payments']); }); } /** * 구독 취소 */ public function cancel(int $id, ?string $reason = null): Subscription { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $subscription = Subscription::query() ->where('tenant_id', $tenantId) ->findOrFail($id); if (! $subscription->isCancellable()) { throw new BadRequestHttpException(__('error.subscription.not_cancellable')); } $subscription->cancel($reason); $subscription->updated_by = $userId; $subscription->save(); return $subscription->fresh(['plan']); } /** * 구독 갱신 */ public function renew(int $id, array $data = []): Subscription { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $subscription = Subscription::query() ->where('tenant_id', $tenantId) ->findOrFail($id); if ($subscription->status !== Subscription::STATUS_ACTIVE) { throw new BadRequestHttpException(__('error.subscription.not_renewable')); } return DB::transaction(function () use ($subscription, $data, $userId) { $plan = $subscription->plan; // 결제 생성 if ($plan->price > 0) { $payment = Payment::create([ 'subscription_id' => $subscription->id, 'amount' => $plan->price, 'payment_method' => $data['payment_method'] ?? Payment::METHOD_CARD, 'status' => Payment::STATUS_PENDING, 'created_by' => $userId, 'updated_by' => $userId, ]); // 결제 완료 처리 if (! empty($data['auto_complete']) && $data['auto_complete']) { $payment->complete($data['transaction_id'] ?? null); $subscription->renew(); } } else { // 무료 갱신 Payment::create([ 'subscription_id' => $subscription->id, 'amount' => 0, 'payment_method' => Payment::METHOD_FREE, 'status' => Payment::STATUS_COMPLETED, 'paid_at' => now(), 'created_by' => $userId, 'updated_by' => $userId, ]); $subscription->renew(); } $subscription->updated_by = $userId; $subscription->save(); return $subscription->fresh(['plan', 'payments']); }); } // ========================================================================= // 구독 상태 관리 // ========================================================================= /** * 구독 일시정지 */ public function suspend(int $id): Subscription { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $subscription = Subscription::query() ->where('tenant_id', $tenantId) ->findOrFail($id); if (! $subscription->suspend()) { throw new BadRequestHttpException(__('error.subscription.not_suspendable')); } $subscription->updated_by = $userId; $subscription->save(); return $subscription->fresh(['plan']); } /** * 구독 재개 */ public function resume(int $id): Subscription { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $subscription = Subscription::query() ->where('tenant_id', $tenantId) ->findOrFail($id); if (! $subscription->resume()) { throw new BadRequestHttpException(__('error.subscription.not_resumable')); } $subscription->updated_by = $userId; $subscription->save(); return $subscription->fresh(['plan']); } // ========================================================================= // 사용량 조회 // ========================================================================= /** * 사용량 조회 (이용현황 통합) * - 사용자 수, 저장공간, AI 토큰, 구독 정보 */ public function usage(): array { $tenantId = $this->tenantId(); $tenant = Tenant::withoutGlobalScopes()->findOrFail($tenantId); // 사용자 수 $userCount = $tenant->users()->count(); $maxUsers = $tenant->max_users ?? 0; // 저장공간 $storageUsed = $tenant->storage_used ?? 0; $storageLimit = $tenant->storage_limit ?? 0; // AI 토큰 (이번 달) $currentMonth = now()->format('Y-m'); $aiTokenLimit = $tenant->ai_token_limit ?? 1000000; $aiStats = AiTokenUsage::where('tenant_id', $tenantId) ->whereRaw("DATE_FORMAT(created_at, '%Y-%m') = ?", [$currentMonth]) ->selectRaw(' COUNT(*) as total_requests, COALESCE(SUM(prompt_tokens), 0) as prompt_tokens, COALESCE(SUM(completion_tokens), 0) as completion_tokens, COALESCE(SUM(total_tokens), 0) as total_tokens, COALESCE(SUM(cost_usd), 0) as cost_usd, COALESCE(SUM(cost_krw), 0) as cost_krw ') ->first(); $aiByModel = AiTokenUsage::where('tenant_id', $tenantId) ->whereRaw("DATE_FORMAT(created_at, '%Y-%m') = ?", [$currentMonth]) ->selectRaw(' model, COUNT(*) as requests, COALESCE(SUM(total_tokens), 0) as total_tokens, COALESCE(SUM(cost_krw), 0) as cost_krw ') ->groupBy('model') ->orderByDesc('total_tokens') ->get(); $totalTokens = (int) $aiStats->total_tokens; $aiPercentage = $aiTokenLimit > 0 ? round(($totalTokens / $aiTokenLimit) * 100, 1) : 0; // 구독 정보 (tenant_id 기반 최신 활성 구독) $subscription = Subscription::with('plan') ->where('tenant_id', $tenantId) ->orderByDesc('created_at') ->first(); return [ 'users' => [ 'used' => $userCount, 'limit' => $maxUsers, 'percentage' => $maxUsers > 0 ? round(($userCount / $maxUsers) * 100, 1) : 0, ], 'storage' => [ 'used' => $storageUsed, 'used_formatted' => $tenant->getStorageUsedFormatted(), 'limit' => $storageLimit, 'limit_formatted' => $tenant->getStorageLimitFormatted(), 'percentage' => $storageLimit > 0 ? round(($storageUsed / $storageLimit) * 100, 1) : 0, ], 'ai_tokens' => [ 'period' => $currentMonth, 'total_requests' => (int) $aiStats->total_requests, 'total_tokens' => $totalTokens, 'prompt_tokens' => (int) $aiStats->prompt_tokens, 'completion_tokens' => (int) $aiStats->completion_tokens, 'limit' => $aiTokenLimit, 'percentage' => $aiPercentage, 'cost_usd' => round((float) $aiStats->cost_usd, 4), 'cost_krw' => round((float) $aiStats->cost_krw), 'warning_threshold' => 80, 'is_over_limit' => $totalTokens > $aiTokenLimit, 'by_model' => $aiByModel->map(fn ($m) => [ 'model' => $m->model, 'requests' => (int) $m->requests, 'total_tokens' => (int) $m->total_tokens, 'cost_krw' => round((float) $m->cost_krw), ])->values()->toArray(), ], 'subscription' => [ 'plan' => $subscription?->plan?->name, 'monthly_fee' => (int) ($subscription?->plan?->price ?? 0), 'status' => $subscription?->status ?? 'active', 'started_at' => $subscription?->started_at?->toDateString(), 'ended_at' => $subscription?->ended_at?->toDateString(), 'remaining_days' => $subscription?->ended_at ? max(0, (int) now()->diffInDays($subscription->ended_at, false)) : null, ], ]; } // ========================================================================= // 데이터 내보내기 // ========================================================================= /** * 내보내기 요청 생성 (동기 처리) */ public function createExport(array $data, ExportService $exportService): DataExport { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // 5분 이상 stuck된 pending/processing 내보내기 자동 만료 처리 DataExport::where('tenant_id', $tenantId) ->whereIn('status', [DataExport::STATUS_PENDING, DataExport::STATUS_PROCESSING]) ->where('created_at', '<', now()->subMinutes(5)) ->each(fn (DataExport $e) => $e->markAsFailed('시간 초과로 자동 만료')); // 진행 중인 내보내기가 있는지 확인 $pendingExport = DataExport::where('tenant_id', $tenantId) ->whereIn('status', [DataExport::STATUS_PENDING, DataExport::STATUS_PROCESSING]) ->first(); if ($pendingExport) { throw new BadRequestHttpException(__('error.export.already_in_progress')); } $export = DataExport::create([ 'tenant_id' => $tenantId, 'export_type' => $data['export_type'] ?? DataExport::TYPE_ALL, 'status' => DataExport::STATUS_PENDING, 'options' => $data['options'] ?? null, 'created_by' => $userId, ]); // 동기 처리: 즉시 파일 생성 try { $export->markAsProcessing(); $exportData = $this->getSubscriptionExportData($data['export_type'] ?? DataExport::TYPE_ALL); $filename = 'exports/subscriptions_'.$tenantId.'_'.date('Ymd_His').'.xlsx'; $exportService->store( $exportData['data'], $exportData['headings'], $filename, '구독관리' ); $filePath = storage_path('app/'.$filename); $fileSize = file_exists($filePath) ? filesize($filePath) : 0; $export->markAsCompleted( $filename, basename($filename), $fileSize ); } catch (\Throwable $e) { Log::error('구독 내보내기 실패', ['error' => $e->getMessage()]); $export->markAsFailed($e->getMessage()); } return $export->fresh(); } /** * 구독 내보내기 데이터 준비 */ private function getSubscriptionExportData(string $exportType): array { $tenantId = $this->tenantId(); $query = Subscription::query() ->where('tenant_id', $tenantId) ->with(['plan:id,name,code,price,billing_cycle']); $subscriptions = $query->orderBy('started_at', 'desc')->get(); $headings = ['No', '요금제', '요금제 코드', '월 요금', '결제주기', '시작일', '종료일', '상태', '취소일', '취소 사유']; $data = $subscriptions->map(function ($sub, $index) { return [ $index + 1, $sub->plan?->name ?? '-', $sub->plan?->code ?? '-', $sub->plan?->price ? number_format($sub->plan->price) : '0', $sub->plan?->billing_cycle === 'yearly' ? '연간' : '월간', $sub->started_at?->format('Y-m-d') ?? '-', $sub->ended_at?->format('Y-m-d') ?? '-', $sub->status_label, $sub->cancelled_at?->format('Y-m-d') ?? '-', $sub->cancel_reason ?? '-', ]; })->toArray(); return ['data' => $data, 'headings' => $headings]; } /** * 내보내기 상태 조회 */ public function getExport(int $id): DataExport { $tenantId = $this->tenantId(); $export = DataExport::where('tenant_id', $tenantId)->find($id); if (! $export) { throw new NotFoundHttpException(__('error.export.not_found')); } return $export; } /** * 내보내기 목록 조회 */ public function getExports(array $params = []): LengthAwarePaginator { $tenantId = $this->tenantId(); $query = DataExport::where('tenant_id', $tenantId) ->with('creator:id,name,email'); // 상태 필터 if (! empty($params['status'])) { $query->where('status', $params['status']); } // 유형 필터 if (! empty($params['export_type'])) { $query->where('export_type', $params['export_type']); } $query->orderBy('created_at', 'desc'); $perPage = $params['per_page'] ?? 20; return $query->paginate($perPage); } }