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:
@@ -1,6 +1,6 @@
|
||||
# 논리적 데이터베이스 관계 문서
|
||||
|
||||
> **자동 생성**: 2025-12-19 16:12:19
|
||||
> **자동 생성**: 2025-12-19 16:48:57
|
||||
> **소스**: Eloquent 모델 관계 분석
|
||||
|
||||
## 📊 모델별 관계 현황
|
||||
@@ -368,6 +368,34 @@ ### quotes
|
||||
- **items()**: hasMany → `quote_items`
|
||||
- **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
|
||||
**모델**: `App\Models\Quote\QuoteItem`
|
||||
|
||||
@@ -439,6 +467,12 @@ ### cards
|
||||
- **creator()**: belongsTo → `users`
|
||||
- **updater()**: belongsTo → `users`
|
||||
|
||||
### data_exports
|
||||
**모델**: `App\Models\Tenants\DataExport`
|
||||
|
||||
- **tenant()**: belongsTo → `tenants`
|
||||
- **creator()**: belongsTo → `users`
|
||||
|
||||
### departments
|
||||
**모델**: `App\Models\Tenants\Department`
|
||||
|
||||
|
||||
@@ -85,4 +85,14 @@ public function refund(PaymentActionRequest $request, int $id): JsonResponse
|
||||
|
||||
return ApiResponse::handle('message.payment.refunded', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제 명세서 조회
|
||||
*/
|
||||
public function statement(int $id): JsonResponse
|
||||
{
|
||||
$result = $this->paymentService->statement($id);
|
||||
|
||||
return ApiResponse::handle('message.fetched', $result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
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\SubscriptionIndexRequest;
|
||||
use App\Http\Requests\V1\Subscription\SubscriptionStoreRequest;
|
||||
@@ -95,4 +96,34 @@ public function resume(int $id): JsonResponse
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
33
app/Http/Requests/V1/Subscription/ExportStoreRequest.php
Normal file
33
app/Http/Requests/V1/Subscription/ExportStoreRequest.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
206
app/Models/Tenants/DataExport.php
Normal file
206
app/Models/Tenants/DataExport.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use App\Models\Tenants\Payment;
|
||||
use App\Models\Tenants\Subscription;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
@@ -273,4 +274,84 @@ public function refund(int $id, ?string $reason = null): Payment
|
||||
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
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 Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class SubscriptionService extends Service
|
||||
{
|
||||
@@ -294,4 +297,136 @@ public function resume(int $id): Subscription
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,64 @@
|
||||
* 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
|
||||
{
|
||||
@@ -361,4 +419,37 @@ public function cancel() {}
|
||||
* )
|
||||
*/
|
||||
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() {}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,69 @@
|
||||
*
|
||||
* @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
|
||||
{
|
||||
@@ -359,4 +422,105 @@ public function suspend() {}
|
||||
* )
|
||||
*/
|
||||
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() {}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -295,4 +295,20 @@
|
||||
'already_withdrawn' => '이미 탈퇴한 계정입니다.',
|
||||
'cannot_withdraw' => '탈퇴할 수 없는 상태입니다.',
|
||||
],
|
||||
|
||||
// 구독 관련
|
||||
'subscription' => [
|
||||
'already_active' => '이미 활성화된 구독이 있습니다.',
|
||||
'not_cancellable' => '취소할 수 없는 상태입니다.',
|
||||
'not_renewable' => '갱신할 수 없는 상태입니다.',
|
||||
'not_suspendable' => '일시정지할 수 없는 상태입니다.',
|
||||
'not_resumable' => '재개할 수 없는 상태입니다.',
|
||||
],
|
||||
|
||||
// 데이터 내보내기 관련
|
||||
'export' => [
|
||||
'already_in_progress' => '이미 진행 중인 내보내기가 있습니다.',
|
||||
'not_found' => '내보내기 요청을 찾을 수 없습니다.',
|
||||
'failed' => '내보내기 처리 중 오류가 발생했습니다.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -346,4 +346,25 @@
|
||||
'suspended' => '사용 중지가 완료되었습니다.',
|
||||
'agreements_updated' => '약관 동의 정보가 수정되었습니다.',
|
||||
],
|
||||
|
||||
// 구독 관리
|
||||
'subscription' => [
|
||||
'cancelled' => '구독이 취소되었습니다.',
|
||||
'renewed' => '구독이 갱신되었습니다.',
|
||||
'suspended' => '구독이 일시정지되었습니다.',
|
||||
'resumed' => '구독이 재개되었습니다.',
|
||||
],
|
||||
|
||||
// 데이터 내보내기
|
||||
'export' => [
|
||||
'requested' => '내보내기 요청이 접수되었습니다.',
|
||||
'completed' => '내보내기가 완료되었습니다.',
|
||||
],
|
||||
|
||||
// 결제 관리
|
||||
'payment' => [
|
||||
'completed' => '결제가 완료되었습니다.',
|
||||
'cancelled' => '결제가 취소되었습니다.',
|
||||
'refunded' => '환불이 완료되었습니다.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -453,6 +453,9 @@
|
||||
Route::get('', [SubscriptionController::class, 'index'])->name('v1.subscriptions.index');
|
||||
Route::post('', [SubscriptionController::class, 'store'])->name('v1.subscriptions.store');
|
||||
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::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');
|
||||
@@ -469,6 +472,7 @@
|
||||
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}/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 (매출 관리)
|
||||
|
||||
Reference in New Issue
Block a user