Files
sam-api/app/Services/SubscriptionService.php

548 lines
18 KiB
PHP
Raw Normal View History

<?php
namespace App\Services;
use App\Models\Tenants\AiTokenUsage;
use App\Models\Tenants\DataExport;
use App\Models\Tenants\Payment;
use App\Models\Tenants\Plan;
use App\Models\Tenants\Subscription;
use App\Models\Tenants\Tenant;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class SubscriptionService extends Service
{
// =========================================================================
// 구독 목록/상세
// =========================================================================
/**
* 구독 목록
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->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);
}
}