feat: Phase 8 SaaS 확장 - 구독관리/결제내역 API 추가

- 사용량 조회 API (GET /subscriptions/usage)
- 데이터 내보내기 API (POST/GET /subscriptions/export)
- 결제 명세서 API (GET /payments/{id}/statement)
- DataExport 모델 및 마이그레이션 추가
This commit is contained in:
2025-12-19 16:53:49 +09:00
parent 0d49e4cc75
commit abaff1286e
13 changed files with 868 additions and 1 deletions

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서 # 논리적 데이터베이스 관계 문서
> **자동 생성**: 2025-12-19 16:12:19 > **자동 생성**: 2025-12-19 16:48:57
> **소스**: Eloquent 모델 관계 분석 > **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황 ## 📊 모델별 관계 현황
@@ -368,6 +368,34 @@ ### quotes
- **items()**: hasMany → `quote_items` - **items()**: hasMany → `quote_items`
- **revisions()**: hasMany → `quote_revisions` - **revisions()**: hasMany → `quote_revisions`
### quote_formulas
**모델**: `App\Models\Quote\QuoteFormula`
- **category()**: belongsTo → `quote_formula_categories`
- **ranges()**: hasMany → `quote_formula_ranges`
- **mappings()**: hasMany → `quote_formula_mappings`
- **items()**: hasMany → `quote_formula_items`
### quote_formula_categorys
**모델**: `App\Models\Quote\QuoteFormulaCategory`
- **formulas()**: hasMany → `quote_formulas`
### quote_formula_items
**모델**: `App\Models\Quote\QuoteFormulaItem`
- **formula()**: belongsTo → `quote_formulas`
### quote_formula_mappings
**모델**: `App\Models\Quote\QuoteFormulaMapping`
- **formula()**: belongsTo → `quote_formulas`
### quote_formula_ranges
**모델**: `App\Models\Quote\QuoteFormulaRange`
- **formula()**: belongsTo → `quote_formulas`
### quote_items ### quote_items
**모델**: `App\Models\Quote\QuoteItem` **모델**: `App\Models\Quote\QuoteItem`
@@ -439,6 +467,12 @@ ### cards
- **creator()**: belongsTo → `users` - **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users` - **updater()**: belongsTo → `users`
### data_exports
**모델**: `App\Models\Tenants\DataExport`
- **tenant()**: belongsTo → `tenants`
- **creator()**: belongsTo → `users`
### departments ### departments
**모델**: `App\Models\Tenants\Department` **모델**: `App\Models\Tenants\Department`

View File

@@ -85,4 +85,14 @@ public function refund(PaymentActionRequest $request, int $id): JsonResponse
return ApiResponse::handle('message.payment.refunded', $result); return ApiResponse::handle('message.payment.refunded', $result);
} }
/**
* 결제 명세서 조회
*/
public function statement(int $id): JsonResponse
{
$result = $this->paymentService->statement($id);
return ApiResponse::handle('message.fetched', $result);
}
} }

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Subscription\ExportStoreRequest;
use App\Http\Requests\V1\Subscription\SubscriptionCancelRequest; use App\Http\Requests\V1\Subscription\SubscriptionCancelRequest;
use App\Http\Requests\V1\Subscription\SubscriptionIndexRequest; use App\Http\Requests\V1\Subscription\SubscriptionIndexRequest;
use App\Http\Requests\V1\Subscription\SubscriptionStoreRequest; use App\Http\Requests\V1\Subscription\SubscriptionStoreRequest;
@@ -95,4 +96,34 @@ public function resume(int $id): JsonResponse
return ApiResponse::handle('message.subscription.resumed', $result); return ApiResponse::handle('message.subscription.resumed', $result);
} }
/**
* 사용량 조회
*/
public function usage(): JsonResponse
{
$result = $this->subscriptionService->usage();
return ApiResponse::handle('message.fetched', $result);
}
/**
* 내보내기 요청
*/
public function export(ExportStoreRequest $request): JsonResponse
{
$result = $this->subscriptionService->createExport($request->validated());
return ApiResponse::handle('message.export.requested', $result, 201);
}
/**
* 내보내기 상태 조회
*/
public function exportStatus(int $id): JsonResponse
{
$result = $this->subscriptionService->getExport($id);
return ApiResponse::handle('message.fetched', $result);
}
} }

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests\V1\Subscription;
use App\Models\Tenants\DataExport;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ExportStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'export_type' => ['required', 'string', Rule::in(DataExport::TYPES)],
'options' => ['nullable', 'array'],
'options.format' => ['nullable', 'string', Rule::in(['xlsx', 'csv', 'json'])],
'options.include_deleted' => ['nullable', 'boolean'],
];
}
public function attributes(): array
{
return [
'export_type' => __('field.export_type'),
'options' => __('field.options'),
];
}
}

View File

@@ -0,0 +1,206 @@
<?php
namespace App\Models\Tenants;
use App\Models\Members\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 데이터 내보내기 모델
*
* @property int $id
* @property int $tenant_id 테넌트 ID
* @property string $export_type 내보내기 유형
* @property string $status 상태
* @property string|null $file_path 파일 경로
* @property string|null $file_name 파일명
* @property int|null $file_size 파일 크기
* @property array|null $options 옵션
* @property \Carbon\Carbon|null $started_at 시작 시간
* @property \Carbon\Carbon|null $completed_at 완료 시간
* @property string|null $error_message 에러 메시지
* @property int|null $created_by 생성자
*/
class DataExport extends Model
{
use BelongsToTenant;
// =========================================================================
// 상수 정의
// =========================================================================
/** 내보내기 유형 */
public const TYPE_ALL = 'all';
public const TYPE_USERS = 'users';
public const TYPE_PRODUCTS = 'products';
public const TYPE_ORDERS = 'orders';
public const TYPE_CLIENTS = 'clients';
public const TYPES = [
self::TYPE_ALL,
self::TYPE_USERS,
self::TYPE_PRODUCTS,
self::TYPE_ORDERS,
self::TYPE_CLIENTS,
];
/** 상태 */
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
public const STATUSES = [
self::STATUS_PENDING,
self::STATUS_PROCESSING,
self::STATUS_COMPLETED,
self::STATUS_FAILED,
];
/** 상태 라벨 */
public const STATUS_LABELS = [
self::STATUS_PENDING => '대기중',
self::STATUS_PROCESSING => '처리중',
self::STATUS_COMPLETED => '완료',
self::STATUS_FAILED => '실패',
];
// =========================================================================
// 모델 설정
// =========================================================================
protected $fillable = [
'tenant_id',
'export_type',
'status',
'file_path',
'file_name',
'file_size',
'options',
'started_at',
'completed_at',
'error_message',
'created_by',
];
protected $casts = [
'options' => 'array',
'file_size' => 'integer',
'started_at' => 'datetime',
'completed_at' => 'datetime',
];
protected $attributes = [
'status' => self::STATUS_PENDING,
];
// =========================================================================
// 관계
// =========================================================================
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
// =========================================================================
// 접근자
// =========================================================================
/**
* 상태 라벨
*/
public function getStatusLabelAttribute(): string
{
return self::STATUS_LABELS[$this->status] ?? $this->status;
}
/**
* 파일 크기 포맷
*/
public function getFileSizeFormattedAttribute(): string
{
if (! $this->file_size) {
return '-';
}
$units = ['B', 'KB', 'MB', 'GB'];
$bytes = max($this->file_size, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, 2).' '.$units[$pow];
}
/**
* 완료 여부
*/
public function getIsCompletedAttribute(): bool
{
return $this->status === self::STATUS_COMPLETED;
}
/**
* 다운로드 가능 여부
*/
public function getIsDownloadableAttribute(): bool
{
return $this->status === self::STATUS_COMPLETED && $this->file_path;
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
/**
* 처리 시작
*/
public function markAsProcessing(): bool
{
$this->status = self::STATUS_PROCESSING;
$this->started_at = now();
return $this->save();
}
/**
* 처리 완료
*/
public function markAsCompleted(string $filePath, string $fileName, int $fileSize): bool
{
$this->status = self::STATUS_COMPLETED;
$this->file_path = $filePath;
$this->file_name = $fileName;
$this->file_size = $fileSize;
$this->completed_at = now();
return $this->save();
}
/**
* 처리 실패
*/
public function markAsFailed(string $errorMessage): bool
{
$this->status = self::STATUS_FAILED;
$this->error_message = $errorMessage;
$this->completed_at = now();
return $this->save();
}
}

View File

@@ -4,6 +4,7 @@
use App\Models\Tenants\Payment; use App\Models\Tenants\Payment;
use App\Models\Tenants\Subscription; use App\Models\Tenants\Subscription;
use App\Models\Tenants\Tenant;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -273,4 +274,84 @@ public function refund(int $id, ?string $reason = null): Payment
return $payment->fresh(['subscription.plan']); return $payment->fresh(['subscription.plan']);
} }
// =========================================================================
// 결제 명세서
// =========================================================================
/**
* 결제 명세서 조회
*/
public function statement(int $id): array
{
$tenantId = $this->tenantId();
// 테넌트 검증 및 결제 조회
$subscriptionIds = Subscription::query()
->where('tenant_id', $tenantId)
->pluck('id');
$payment = Payment::query()
->whereIn('subscription_id', $subscriptionIds)
->with(['subscription.plan'])
->findOrFail($id);
// 테넌트 정보 조회
$tenant = Tenant::findOrFail($tenantId);
$subscription = $payment->subscription;
$plan = $subscription->plan;
return [
'statement_no' => sprintf('INV-%s-%06d', $payment->paid_at?->format('Ymd') ?? now()->format('Ymd'), $payment->id),
'issued_at' => now()->toIso8601String(),
'payment' => [
'id' => $payment->id,
'amount' => $payment->amount,
'formatted_amount' => $payment->formatted_amount,
'payment_method' => $payment->payment_method,
'payment_method_label' => $payment->payment_method_label,
'transaction_id' => $payment->transaction_id,
'status' => $payment->status,
'status_label' => $payment->status_label,
'paid_at' => $payment->paid_at?->toIso8601String(),
'memo' => $payment->memo,
],
'subscription' => [
'id' => $subscription->id,
'started_at' => $subscription->started_at?->toDateString(),
'ended_at' => $subscription->ended_at?->toDateString(),
'status' => $subscription->status,
'status_label' => $subscription->status_label,
],
'plan' => $plan ? [
'id' => $plan->id,
'name' => $plan->name,
'code' => $plan->code,
'price' => $plan->price,
'billing_cycle' => $plan->billing_cycle,
'billing_cycle_label' => $plan->billing_cycle_label ?? $plan->billing_cycle,
] : null,
'customer' => [
'tenant_id' => $tenant->id,
'company_name' => $tenant->company_name,
'business_number' => $tenant->business_number ?? null,
'representative' => $tenant->representative ?? null,
'address' => $tenant->address ?? null,
'email' => $tenant->email ?? null,
'phone' => $tenant->phone ?? null,
],
'items' => [
[
'description' => $plan ? sprintf('%s 구독 (%s)', $plan->name, $subscription->started_at?->format('Y.m.d') ?? '-') : '구독 서비스',
'quantity' => 1,
'unit_price' => $payment->amount,
'amount' => $payment->amount,
],
],
'subtotal' => $payment->amount,
'tax' => 0, // VAT 별도 시 계산 필요
'total' => $payment->amount,
];
}
} }

View File

@@ -2,12 +2,15 @@
namespace App\Services; namespace App\Services;
use App\Models\Tenants\DataExport;
use App\Models\Tenants\Payment; use App\Models\Tenants\Payment;
use App\Models\Tenants\Plan; use App\Models\Tenants\Plan;
use App\Models\Tenants\Subscription; use App\Models\Tenants\Subscription;
use App\Models\Tenants\Tenant;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class SubscriptionService extends Service class SubscriptionService extends Service
{ {
@@ -294,4 +297,136 @@ public function resume(int $id): Subscription
return $subscription->fresh(['plan']); return $subscription->fresh(['plan']);
} }
// =========================================================================
// 사용량 조회
// =========================================================================
/**
* 사용량 조회
*/
public function usage(): array
{
$tenantId = $this->tenantId();
$tenant = Tenant::with(['subscription.plan'])->findOrFail($tenantId);
// 사용자 수
$userCount = $tenant->users()->count();
$maxUsers = $tenant->max_users ?? 0;
// 저장공간
$storageUsed = $tenant->storage_used ?? 0;
$storageLimit = $tenant->storage_limit ?? 0;
// 구독 정보
$subscription = $tenant->subscription;
$remainingDays = null;
$planName = null;
if ($subscription && $subscription->is_valid) {
$remainingDays = $subscription->remaining_days;
$planName = $subscription->plan?->name;
}
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,
],
'subscription' => [
'plan' => $planName,
'status' => $subscription?->status,
'remaining_days' => $remainingDays,
'started_at' => $subscription?->started_at?->toDateString(),
'ended_at' => $subscription?->ended_at?->toDateString(),
],
];
}
// =========================================================================
// 데이터 내보내기
// =========================================================================
/**
* 내보내기 요청 생성
*/
public function createExport(array $data): DataExport
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 진행 중인 내보내기가 있는지 확인
$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,
]);
// TODO: 비동기 Job 디스패치
// dispatch(new ProcessDataExport($export));
return $export;
}
/**
* 내보내기 상태 조회
*/
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);
}
} }

View File

@@ -85,6 +85,64 @@
* description="결제 수단별 집계" * description="결제 수단별 집계"
* ) * )
* ) * )
*
* @OA\Schema(
* schema="PaymentStatement",
* type="object",
* description="결제 명세서",
*
* @OA\Property(property="statement_no", type="string", example="INV-20250115-000001", description="명세서 번호"),
* @OA\Property(property="issued_at", type="string", format="date-time", description="발행일시"),
* @OA\Property(property="payment", type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="amount", type="number", format="float", example=29000),
* @OA\Property(property="formatted_amount", type="string", example="29,000원"),
* @OA\Property(property="payment_method", type="string", example="card"),
* @OA\Property(property="payment_method_label", type="string", example="카드"),
* @OA\Property(property="transaction_id", type="string", example="TXN123456789", nullable=true),
* @OA\Property(property="status", type="string", example="completed"),
* @OA\Property(property="status_label", type="string", example="완료"),
* @OA\Property(property="paid_at", type="string", format="date-time", nullable=true),
* @OA\Property(property="memo", type="string", nullable=true)
* ),
* @OA\Property(property="subscription", type="object",
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="started_at", type="string", format="date", example="2025-01-01"),
* @OA\Property(property="ended_at", type="string", format="date", example="2025-02-01", nullable=true),
* @OA\Property(property="status", type="string", example="active"),
* @OA\Property(property="status_label", type="string", example="활성")
* ),
* @OA\Property(property="plan", type="object", nullable=true,
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="스타터"),
* @OA\Property(property="code", type="string", example="starter"),
* @OA\Property(property="price", type="number", format="float", example=29000),
* @OA\Property(property="billing_cycle", type="string", example="monthly"),
* @OA\Property(property="billing_cycle_label", type="string", example="월간")
* ),
* @OA\Property(property="customer", type="object",
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="company_name", type="string", example="테스트 회사"),
* @OA\Property(property="business_number", type="string", example="123-45-67890", nullable=true),
* @OA\Property(property="representative", type="string", example="홍길동", nullable=true),
* @OA\Property(property="address", type="string", example="서울시 강남구", nullable=true),
* @OA\Property(property="email", type="string", example="contact@test.com", nullable=true),
* @OA\Property(property="phone", type="string", example="02-1234-5678", nullable=true)
* ),
* @OA\Property(property="items", type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="description", type="string", example="스타터 구독 (2025.01.01)"),
* @OA\Property(property="quantity", type="integer", example=1),
* @OA\Property(property="unit_price", type="number", format="float", example=29000),
* @OA\Property(property="amount", type="number", format="float", example=29000)
* )
* ),
* @OA\Property(property="subtotal", type="number", format="float", example=29000, description="소계"),
* @OA\Property(property="tax", type="number", format="float", example=0, description="세금"),
* @OA\Property(property="total", type="number", format="float", example=29000, description="총액")
* )
*/ */
class PaymentApi class PaymentApi
{ {
@@ -361,4 +419,37 @@ public function cancel() {}
* ) * )
*/ */
public function refund() {} public function refund() {}
/**
* @OA\Get(
* path="/api/v1/payments/{id}/statement",
* tags={"Payments"},
* summary="결제 명세서 조회",
* description="결제 명세서를 조회합니다. 구독, 요금제, 고객 정보를 포함한 상세 명세서를 반환합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="결제 ID", @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/PaymentStatement")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="결제 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function statement() {}
} }

View File

@@ -56,6 +56,69 @@
* *
* @OA\Property(property="reason", type="string", example="서비스 불만족", maxLength=500, nullable=true, description="취소 사유") * @OA\Property(property="reason", type="string", example="서비스 불만족", maxLength=500, nullable=true, description="취소 사유")
* ) * )
*
* @OA\Schema(
* schema="UsageResponse",
* type="object",
* description="사용량 정보",
*
* @OA\Property(property="users", type="object",
* @OA\Property(property="used", type="integer", example=5, description="현재 사용자 수"),
* @OA\Property(property="limit", type="integer", example=10, description="최대 사용자 수"),
* @OA\Property(property="percentage", type="number", format="float", example=50.0, description="사용률 (%)")
* ),
* @OA\Property(property="storage", type="object",
* @OA\Property(property="used", type="integer", example=1288490188, description="사용 용량 (bytes)"),
* @OA\Property(property="used_formatted", type="string", example="1.2 GB", description="사용 용량 (포맷)"),
* @OA\Property(property="limit", type="integer", example=10737418240, description="최대 용량 (bytes)"),
* @OA\Property(property="limit_formatted", type="string", example="10 GB", description="최대 용량 (포맷)"),
* @OA\Property(property="percentage", type="number", format="float", example=12.0, description="사용률 (%)")
* ),
* @OA\Property(property="subscription", type="object",
* @OA\Property(property="plan", type="string", example="스타터", nullable=true, description="요금제명"),
* @OA\Property(property="status", type="string", example="active", nullable=true, description="구독 상태"),
* @OA\Property(property="remaining_days", type="integer", example=25, nullable=true, description="남은 일수"),
* @OA\Property(property="started_at", type="string", format="date", example="2025-01-01", nullable=true),
* @OA\Property(property="ended_at", type="string", format="date", example="2025-02-01", nullable=true)
* )
* )
*
* @OA\Schema(
* schema="DataExport",
* type="object",
* description="데이터 내보내기 정보",
*
* @OA\Property(property="id", type="integer", example=1, description="ID"),
* @OA\Property(property="tenant_id", type="integer", example=1, description="테넌트 ID"),
* @OA\Property(property="export_type", type="string", enum={"all","users","products","orders","clients"}, example="all", description="내보내기 유형"),
* @OA\Property(property="status", type="string", enum={"pending","processing","completed","failed"}, example="pending", description="상태"),
* @OA\Property(property="status_label", type="string", example="대기중", description="상태 라벨"),
* @OA\Property(property="file_path", type="string", nullable=true, description="파일 경로"),
* @OA\Property(property="file_name", type="string", nullable=true, description="파일명"),
* @OA\Property(property="file_size", type="integer", nullable=true, description="파일 크기 (bytes)"),
* @OA\Property(property="file_size_formatted", type="string", example="1.5 MB", description="파일 크기 (포맷)"),
* @OA\Property(property="options", type="object", nullable=true, description="내보내기 옵션"),
* @OA\Property(property="started_at", type="string", format="date-time", nullable=true, description="시작 시간"),
* @OA\Property(property="completed_at", type="string", format="date-time", nullable=true, description="완료 시간"),
* @OA\Property(property="error_message", type="string", nullable=true, description="에러 메시지"),
* @OA\Property(property="is_completed", type="boolean", example=false, description="완료 여부"),
* @OA\Property(property="is_downloadable", type="boolean", example=false, description="다운로드 가능 여부"),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time")
* )
*
* @OA\Schema(
* schema="ExportCreateRequest",
* type="object",
* required={"export_type"},
* description="내보내기 요청",
*
* @OA\Property(property="export_type", type="string", enum={"all","users","products","orders","clients"}, example="all", description="내보내기 유형"),
* @OA\Property(property="options", type="object", nullable=true,
* @OA\Property(property="format", type="string", enum={"xlsx","csv","json"}, example="xlsx", description="파일 포맷"),
* @OA\Property(property="include_deleted", type="boolean", example=false, description="삭제된 데이터 포함 여부")
* )
* )
*/ */
class SubscriptionApi class SubscriptionApi
{ {
@@ -359,4 +422,105 @@ public function suspend() {}
* ) * )
*/ */
public function resume() {} public function resume() {}
/**
* @OA\Get(
* path="/api/v1/subscriptions/usage",
* tags={"Subscriptions"},
* summary="사용량 조회",
* description="테넌트의 사용자, 저장소, 구독 사용량 정보를 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/UsageResponse")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function usage() {}
/**
* @OA\Post(
* path="/api/v1/subscriptions/export",
* tags={"Subscriptions"},
* summary="데이터 내보내기 요청",
* description="테넌트 데이터를 내보내기 요청합니다. 백그라운드에서 처리되며 완료 후 다운로드 가능합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/ExportCreateRequest")
* ),
*
* @OA\Response(
* response=201,
* description="요청 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/DataExport")
* )
* }
* )
* ),
*
* @OA\Response(response=400, description="진행 중인 내보내기 존재", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="유효성 검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function export() {}
/**
* @OA\Get(
* path="/api/v1/subscriptions/export/{id}",
* tags={"Subscriptions"},
* summary="내보내기 상태 조회",
* description="내보내기 요청의 현재 상태를 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="내보내기 ID", @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/DataExport")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="내보내기 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function exportStatus() {}
} }

View File

@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('data_exports', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete()->comment('테넌트 ID');
$table->string('export_type', 50)->comment('내보내기 유형: all, users, products, orders, clients');
$table->string('status', 20)->default('pending')->comment('상태: pending, processing, completed, failed');
$table->string('file_path')->nullable()->comment('생성된 파일 경로');
$table->string('file_name')->nullable()->comment('다운로드 파일명');
$table->unsignedBigInteger('file_size')->nullable()->comment('파일 크기 (bytes)');
$table->json('options')->nullable()->comment('내보내기 옵션');
$table->timestamp('started_at')->nullable()->comment('처리 시작 시간');
$table->timestamp('completed_at')->nullable()->comment('처리 완료 시간');
$table->text('error_message')->nullable()->comment('에러 메시지');
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('생성자');
$table->timestamps();
$table->index(['tenant_id', 'status']);
$table->index(['tenant_id', 'created_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('data_exports');
}
};

View File

@@ -295,4 +295,20 @@
'already_withdrawn' => '이미 탈퇴한 계정입니다.', 'already_withdrawn' => '이미 탈퇴한 계정입니다.',
'cannot_withdraw' => '탈퇴할 수 없는 상태입니다.', 'cannot_withdraw' => '탈퇴할 수 없는 상태입니다.',
], ],
// 구독 관련
'subscription' => [
'already_active' => '이미 활성화된 구독이 있습니다.',
'not_cancellable' => '취소할 수 없는 상태입니다.',
'not_renewable' => '갱신할 수 없는 상태입니다.',
'not_suspendable' => '일시정지할 수 없는 상태입니다.',
'not_resumable' => '재개할 수 없는 상태입니다.',
],
// 데이터 내보내기 관련
'export' => [
'already_in_progress' => '이미 진행 중인 내보내기가 있습니다.',
'not_found' => '내보내기 요청을 찾을 수 없습니다.',
'failed' => '내보내기 처리 중 오류가 발생했습니다.',
],
]; ];

View File

@@ -346,4 +346,25 @@
'suspended' => '사용 중지가 완료되었습니다.', 'suspended' => '사용 중지가 완료되었습니다.',
'agreements_updated' => '약관 동의 정보가 수정되었습니다.', 'agreements_updated' => '약관 동의 정보가 수정되었습니다.',
], ],
// 구독 관리
'subscription' => [
'cancelled' => '구독이 취소되었습니다.',
'renewed' => '구독이 갱신되었습니다.',
'suspended' => '구독이 일시정지되었습니다.',
'resumed' => '구독이 재개되었습니다.',
],
// 데이터 내보내기
'export' => [
'requested' => '내보내기 요청이 접수되었습니다.',
'completed' => '내보내기가 완료되었습니다.',
],
// 결제 관리
'payment' => [
'completed' => '결제가 완료되었습니다.',
'cancelled' => '결제가 취소되었습니다.',
'refunded' => '환불이 완료되었습니다.',
],
]; ];

View File

@@ -453,6 +453,9 @@
Route::get('', [SubscriptionController::class, 'index'])->name('v1.subscriptions.index'); Route::get('', [SubscriptionController::class, 'index'])->name('v1.subscriptions.index');
Route::post('', [SubscriptionController::class, 'store'])->name('v1.subscriptions.store'); Route::post('', [SubscriptionController::class, 'store'])->name('v1.subscriptions.store');
Route::get('/current', [SubscriptionController::class, 'current'])->name('v1.subscriptions.current'); Route::get('/current', [SubscriptionController::class, 'current'])->name('v1.subscriptions.current');
Route::get('/usage', [SubscriptionController::class, 'usage'])->name('v1.subscriptions.usage');
Route::post('/export', [SubscriptionController::class, 'export'])->name('v1.subscriptions.export');
Route::get('/export/{id}', [SubscriptionController::class, 'exportStatus'])->whereNumber('id')->name('v1.subscriptions.export.status');
Route::get('/{id}', [SubscriptionController::class, 'show'])->whereNumber('id')->name('v1.subscriptions.show'); Route::get('/{id}', [SubscriptionController::class, 'show'])->whereNumber('id')->name('v1.subscriptions.show');
Route::post('/{id}/cancel', [SubscriptionController::class, 'cancel'])->whereNumber('id')->name('v1.subscriptions.cancel'); Route::post('/{id}/cancel', [SubscriptionController::class, 'cancel'])->whereNumber('id')->name('v1.subscriptions.cancel');
Route::post('/{id}/renew', [SubscriptionController::class, 'renew'])->whereNumber('id')->name('v1.subscriptions.renew'); Route::post('/{id}/renew', [SubscriptionController::class, 'renew'])->whereNumber('id')->name('v1.subscriptions.renew');
@@ -469,6 +472,7 @@
Route::post('/{id}/complete', [PaymentController::class, 'complete'])->whereNumber('id')->name('v1.payments.complete'); Route::post('/{id}/complete', [PaymentController::class, 'complete'])->whereNumber('id')->name('v1.payments.complete');
Route::post('/{id}/cancel', [PaymentController::class, 'cancel'])->whereNumber('id')->name('v1.payments.cancel'); Route::post('/{id}/cancel', [PaymentController::class, 'cancel'])->whereNumber('id')->name('v1.payments.cancel');
Route::post('/{id}/refund', [PaymentController::class, 'refund'])->whereNumber('id')->name('v1.payments.refund'); Route::post('/{id}/refund', [PaymentController::class, 'refund'])->whereNumber('id')->name('v1.payments.refund');
Route::get('/{id}/statement', [PaymentController::class, 'statement'])->whereNumber('id')->name('v1.payments.statement');
}); });
// Sale API (매출 관리) // Sale API (매출 관리)