diff --git a/app/Http/Controllers/Api/V1/BankAccountController.php b/app/Http/Controllers/Api/V1/BankAccountController.php index c246240..44e3c06 100644 --- a/app/Http/Controllers/Api/V1/BankAccountController.php +++ b/app/Http/Controllers/Api/V1/BankAccountController.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers\Api\V1; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\V1\BankAccount\StoreBankAccountRequest; use App\Http\Requests\V1\BankAccount\UpdateBankAccountRequest; -use App\Helpers\ApiResponse; use App\Services\BankAccountService; use Illuminate\Http\Request; diff --git a/app/Http/Controllers/Api/V1/CardController.php b/app/Http/Controllers/Api/V1/CardController.php index db51418..2d3ef1f 100644 --- a/app/Http/Controllers/Api/V1/CardController.php +++ b/app/Http/Controllers/Api/V1/CardController.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers\Api\V1; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\V1\Card\StoreCardRequest; use App\Http\Requests\V1\Card\UpdateCardRequest; -use App\Helpers\ApiResponse; use App\Services\CardService; use Illuminate\Http\Request; diff --git a/app/Http/Controllers/Api/V1/DepositController.php b/app/Http/Controllers/Api/V1/DepositController.php index e95ce51..6436279 100644 --- a/app/Http/Controllers/Api/V1/DepositController.php +++ b/app/Http/Controllers/Api/V1/DepositController.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers\Api\V1; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\V1\Deposit\StoreDepositRequest; use App\Http\Requests\V1\Deposit\UpdateDepositRequest; -use App\Helpers\ApiResponse; use App\Services\DepositService; use Illuminate\Http\Request; diff --git a/app/Http/Controllers/Api/V1/PaymentController.php b/app/Http/Controllers/Api/V1/PaymentController.php new file mode 100644 index 0000000..5981c05 --- /dev/null +++ b/app/Http/Controllers/Api/V1/PaymentController.php @@ -0,0 +1,88 @@ +paymentService->index($request->validated()); + + return ApiResponse::handle('message.fetched', $result); + } + + /** + * 결제 요약 통계 + */ + public function summary(PaymentIndexRequest $request): JsonResponse + { + $result = $this->paymentService->summary($request->validated()); + + return ApiResponse::handle('message.fetched', $result); + } + + /** + * 결제 상세 + */ + public function show(int $id): JsonResponse + { + $result = $this->paymentService->show($id); + + return ApiResponse::handle('message.fetched', $result); + } + + /** + * 결제 등록 (수동) + */ + public function store(PaymentStoreRequest $request): JsonResponse + { + $result = $this->paymentService->store($request->validated()); + + return ApiResponse::handle('message.created', $result, 201); + } + + /** + * 결제 완료 처리 + */ + public function complete(PaymentActionRequest $request, int $id): JsonResponse + { + $result = $this->paymentService->complete($id, $request->validated()['transaction_id'] ?? null); + + return ApiResponse::handle('message.payment.completed', $result); + } + + /** + * 결제 취소 + */ + public function cancel(PaymentActionRequest $request, int $id): JsonResponse + { + $result = $this->paymentService->cancel($id, $request->validated()['reason'] ?? null); + + return ApiResponse::handle('message.payment.cancelled', $result); + } + + /** + * 환불 처리 + */ + public function refund(PaymentActionRequest $request, int $id): JsonResponse + { + $result = $this->paymentService->refund($id, $request->validated()['reason'] ?? null); + + return ApiResponse::handle('message.payment.refunded', $result); + } +} diff --git a/app/Http/Controllers/Api/V1/PayrollController.php b/app/Http/Controllers/Api/V1/PayrollController.php index a34d04f..4c8af6f 100644 --- a/app/Http/Controllers/Api/V1/PayrollController.php +++ b/app/Http/Controllers/Api/V1/PayrollController.php @@ -2,13 +2,13 @@ namespace App\Http\Controllers\Api\V1; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\V1\Payroll\CalculatePayrollRequest; use App\Http\Requests\V1\Payroll\PayPayrollRequest; use App\Http\Requests\V1\Payroll\StorePayrollRequest; use App\Http\Requests\V1\Payroll\UpdatePayrollRequest; use App\Http\Requests\V1\Payroll\UpdatePayrollSettingRequest; -use App\Helpers\ApiResponse; use App\Services\PayrollService; use Illuminate\Http\Request; diff --git a/app/Http/Controllers/Api/V1/PlanController.php b/app/Http/Controllers/Api/V1/PlanController.php new file mode 100644 index 0000000..a3ce0b9 --- /dev/null +++ b/app/Http/Controllers/Api/V1/PlanController.php @@ -0,0 +1,88 @@ +planService->index($request->validated()); + + return ApiResponse::handle('message.fetched', $result); + } + + /** + * 활성 요금제 목록 (공개용) + */ + public function active(): JsonResponse + { + $result = $this->planService->active(); + + return ApiResponse::handle('message.fetched', $result); + } + + /** + * 요금제 등록 + */ + public function store(PlanStoreRequest $request): JsonResponse + { + $result = $this->planService->store($request->validated()); + + return ApiResponse::handle('message.created', $result, 201); + } + + /** + * 요금제 상세 + */ + public function show(int $id): JsonResponse + { + $result = $this->planService->show($id); + + return ApiResponse::handle('message.fetched', $result); + } + + /** + * 요금제 수정 + */ + public function update(PlanUpdateRequest $request, int $id): JsonResponse + { + $result = $this->planService->update($id, $request->validated()); + + return ApiResponse::handle('message.updated', $result); + } + + /** + * 요금제 삭제 + */ + public function destroy(int $id): JsonResponse + { + $this->planService->destroy($id); + + return ApiResponse::handle('message.deleted'); + } + + /** + * 요금제 활성/비활성 토글 + */ + public function toggle(int $id): JsonResponse + { + $result = $this->planService->toggle($id); + + return ApiResponse::handle('message.updated', $result); + } +} diff --git a/app/Http/Controllers/Api/V1/PurchaseController.php b/app/Http/Controllers/Api/V1/PurchaseController.php index 07cabfb..c3a2154 100644 --- a/app/Http/Controllers/Api/V1/PurchaseController.php +++ b/app/Http/Controllers/Api/V1/PurchaseController.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers\Api\V1; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\V1\Purchase\StorePurchaseRequest; use App\Http\Requests\V1\Purchase\UpdatePurchaseRequest; -use App\Helpers\ApiResponse; use App\Services\PurchaseService; use Illuminate\Http\Request; diff --git a/app/Http/Controllers/Api/V1/ReportController.php b/app/Http/Controllers/Api/V1/ReportController.php index f60f934..c40cfe1 100644 --- a/app/Http/Controllers/Api/V1/ReportController.php +++ b/app/Http/Controllers/Api/V1/ReportController.php @@ -4,10 +4,10 @@ use App\Exports\DailyReportExport; use App\Exports\ExpenseEstimateExport; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\V1\Report\DailyReportRequest; use App\Http\Requests\V1\Report\ExpenseEstimateRequest; -use App\Helpers\ApiResponse; use App\Services\ReportService; use Maatwebsite\Excel\Facades\Excel; diff --git a/app/Http/Controllers/Api/V1/SaleController.php b/app/Http/Controllers/Api/V1/SaleController.php index 619491b..9a1bcd1 100644 --- a/app/Http/Controllers/Api/V1/SaleController.php +++ b/app/Http/Controllers/Api/V1/SaleController.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers\Api\V1; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\V1\Sale\StoreSaleRequest; use App\Http\Requests\V1\Sale\UpdateSaleRequest; -use App\Helpers\ApiResponse; use App\Services\SaleService; use Illuminate\Http\Request; diff --git a/app/Http/Controllers/Api/V1/SiteController.php b/app/Http/Controllers/Api/V1/SiteController.php index 2f5acc5..93dbc76 100644 --- a/app/Http/Controllers/Api/V1/SiteController.php +++ b/app/Http/Controllers/Api/V1/SiteController.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers\Api\V1; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\V1\Site\StoreSiteRequest; use App\Http\Requests\V1\Site\UpdateSiteRequest; -use App\Helpers\ApiResponse; use App\Services\SiteService; use Illuminate\Http\Request; diff --git a/app/Http/Controllers/Api/V1/SubscriptionController.php b/app/Http/Controllers/Api/V1/SubscriptionController.php new file mode 100644 index 0000000..b205c58 --- /dev/null +++ b/app/Http/Controllers/Api/V1/SubscriptionController.php @@ -0,0 +1,98 @@ +subscriptionService->index($request->validated()); + + return ApiResponse::handle('message.fetched', $result); + } + + /** + * 현재 활성 구독 + */ + public function current(): JsonResponse + { + $result = $this->subscriptionService->current(); + + return ApiResponse::handle('message.fetched', $result); + } + + /** + * 구독 등록 + */ + public function store(SubscriptionStoreRequest $request): JsonResponse + { + $result = $this->subscriptionService->store($request->validated()); + + return ApiResponse::handle('message.created', $result, 201); + } + + /** + * 구독 상세 + */ + public function show(int $id): JsonResponse + { + $result = $this->subscriptionService->show($id); + + return ApiResponse::handle('message.fetched', $result); + } + + /** + * 구독 취소 + */ + public function cancel(SubscriptionCancelRequest $request, int $id): JsonResponse + { + $result = $this->subscriptionService->cancel($id, $request->validated()['reason'] ?? null); + + return ApiResponse::handle('message.subscription.cancelled', $result); + } + + /** + * 구독 갱신 + */ + public function renew(SubscriptionStoreRequest $request, int $id): JsonResponse + { + $result = $this->subscriptionService->renew($id, $request->validated()); + + return ApiResponse::handle('message.subscription.renewed', $result); + } + + /** + * 구독 일시정지 + */ + public function suspend(int $id): JsonResponse + { + $result = $this->subscriptionService->suspend($id); + + return ApiResponse::handle('message.subscription.suspended', $result); + } + + /** + * 구독 재개 + */ + public function resume(int $id): JsonResponse + { + $result = $this->subscriptionService->resume($id); + + return ApiResponse::handle('message.subscription.resumed', $result); + } +} diff --git a/app/Http/Controllers/Api/V1/WithdrawalController.php b/app/Http/Controllers/Api/V1/WithdrawalController.php index deaadf1..06eea38 100644 --- a/app/Http/Controllers/Api/V1/WithdrawalController.php +++ b/app/Http/Controllers/Api/V1/WithdrawalController.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers\Api\V1; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\V1\Withdrawal\StoreWithdrawalRequest; use App\Http\Requests\V1\Withdrawal\UpdateWithdrawalRequest; -use App\Helpers\ApiResponse; use App\Services\WithdrawalService; use Illuminate\Http\Request; diff --git a/app/Http/Controllers/Api/V1/WorkSettingController.php b/app/Http/Controllers/Api/V1/WorkSettingController.php index 4b782c8..8f80b5f 100644 --- a/app/Http/Controllers/Api/V1/WorkSettingController.php +++ b/app/Http/Controllers/Api/V1/WorkSettingController.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers\Api\V1; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\V1\WorkSetting\UpdateAttendanceSettingRequest; use App\Http\Requests\V1\WorkSetting\UpdateWorkSettingRequest; -use App\Helpers\ApiResponse; use App\Services\WorkSettingService; class WorkSettingController extends Controller diff --git a/app/Http/Requests/V1/Payment/PaymentActionRequest.php b/app/Http/Requests/V1/Payment/PaymentActionRequest.php new file mode 100644 index 0000000..cb43b18 --- /dev/null +++ b/app/Http/Requests/V1/Payment/PaymentActionRequest.php @@ -0,0 +1,21 @@ + ['nullable', 'string', 'max:500'], + 'transaction_id' => ['nullable', 'string', 'max:100'], + ]; + } +} diff --git a/app/Http/Requests/V1/Payment/PaymentIndexRequest.php b/app/Http/Requests/V1/Payment/PaymentIndexRequest.php new file mode 100644 index 0000000..1354e70 --- /dev/null +++ b/app/Http/Requests/V1/Payment/PaymentIndexRequest.php @@ -0,0 +1,29 @@ + ['nullable', 'string', Rule::in(Payment::STATUSES)], + 'payment_method' => ['nullable', 'string', Rule::in(Payment::PAYMENT_METHODS)], + 'start_date' => ['nullable', 'date'], + 'end_date' => ['nullable', 'date', 'after_or_equal:start_date'], + 'search' => ['nullable', 'string', 'max:100'], + 'sort_by' => ['nullable', 'string', 'in:created_at,paid_at,amount'], + 'sort_dir' => ['nullable', 'string', 'in:asc,desc'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/app/Http/Requests/V1/Payment/PaymentStoreRequest.php b/app/Http/Requests/V1/Payment/PaymentStoreRequest.php new file mode 100644 index 0000000..2e53a86 --- /dev/null +++ b/app/Http/Requests/V1/Payment/PaymentStoreRequest.php @@ -0,0 +1,27 @@ + ['required', 'integer', 'exists:subscriptions,id'], + 'amount' => ['required', 'numeric', 'min:0'], + 'payment_method' => ['required', 'string', Rule::in(Payment::PAYMENT_METHODS)], + 'transaction_id' => ['nullable', 'string', 'max:100'], + 'memo' => ['nullable', 'string', 'max:500'], + 'auto_complete' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/V1/Plan/PlanIndexRequest.php b/app/Http/Requests/V1/Plan/PlanIndexRequest.php new file mode 100644 index 0000000..731022c --- /dev/null +++ b/app/Http/Requests/V1/Plan/PlanIndexRequest.php @@ -0,0 +1,25 @@ + ['nullable', 'boolean'], + 'billing_cycle' => ['nullable', 'string', 'in:monthly,yearly,lifetime'], + 'search' => ['nullable', 'string', 'max:100'], + 'sort_by' => ['nullable', 'string', 'in:price,name,created_at'], + 'sort_dir' => ['nullable', 'string', 'in:asc,desc'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/app/Http/Requests/V1/Plan/PlanStoreRequest.php b/app/Http/Requests/V1/Plan/PlanStoreRequest.php new file mode 100644 index 0000000..ee91ca4 --- /dev/null +++ b/app/Http/Requests/V1/Plan/PlanStoreRequest.php @@ -0,0 +1,29 @@ + ['required', 'string', 'max:100'], + 'code' => ['required', 'string', 'max:50', 'unique:plans,code'], + 'description' => ['nullable', 'string', 'max:500'], + 'price' => ['required', 'numeric', 'min:0'], + 'billing_cycle' => ['required', 'string', Rule::in(Plan::BILLING_CYCLES)], + 'features' => ['nullable', 'array'], + 'features.*' => ['string', 'max:200'], + 'is_active' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/V1/Plan/PlanUpdateRequest.php b/app/Http/Requests/V1/Plan/PlanUpdateRequest.php new file mode 100644 index 0000000..7cbb77c --- /dev/null +++ b/app/Http/Requests/V1/Plan/PlanUpdateRequest.php @@ -0,0 +1,31 @@ +route('id'); + + return [ + 'name' => ['sometimes', 'required', 'string', 'max:100'], + 'code' => ['sometimes', 'required', 'string', 'max:50', Rule::unique('plans', 'code')->ignore($planId)], + 'description' => ['nullable', 'string', 'max:500'], + 'price' => ['sometimes', 'required', 'numeric', 'min:0'], + 'billing_cycle' => ['sometimes', 'required', 'string', Rule::in(Plan::BILLING_CYCLES)], + 'features' => ['nullable', 'array'], + 'features.*' => ['string', 'max:200'], + 'is_active' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/V1/Subscription/SubscriptionCancelRequest.php b/app/Http/Requests/V1/Subscription/SubscriptionCancelRequest.php new file mode 100644 index 0000000..c7517f4 --- /dev/null +++ b/app/Http/Requests/V1/Subscription/SubscriptionCancelRequest.php @@ -0,0 +1,20 @@ + ['nullable', 'string', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/V1/Subscription/SubscriptionIndexRequest.php b/app/Http/Requests/V1/Subscription/SubscriptionIndexRequest.php new file mode 100644 index 0000000..58f362c --- /dev/null +++ b/app/Http/Requests/V1/Subscription/SubscriptionIndexRequest.php @@ -0,0 +1,29 @@ + ['nullable', 'string', Rule::in(Subscription::STATUSES)], + 'valid_only' => ['nullable', 'boolean'], + 'expiring_within' => ['nullable', 'integer', 'min:1', 'max:365'], + 'start_date' => ['nullable', 'date'], + 'end_date' => ['nullable', 'date', 'after_or_equal:start_date'], + 'sort_by' => ['nullable', 'string', 'in:started_at,ended_at,created_at'], + 'sort_dir' => ['nullable', 'string', 'in:asc,desc'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/app/Http/Requests/V1/Subscription/SubscriptionStoreRequest.php b/app/Http/Requests/V1/Subscription/SubscriptionStoreRequest.php new file mode 100644 index 0000000..ed0cac9 --- /dev/null +++ b/app/Http/Requests/V1/Subscription/SubscriptionStoreRequest.php @@ -0,0 +1,26 @@ + ['required', 'integer', 'exists:plans,id'], + 'started_at' => ['nullable', 'date'], + 'payment_method' => ['nullable', 'string', Rule::in(Payment::PAYMENT_METHODS)], + 'transaction_id' => ['nullable', 'string', 'max:100'], + 'auto_complete' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Models/Tenants/Payment.php b/app/Models/Tenants/Payment.php index 46d6ea6..1bbe7b9 100644 --- a/app/Models/Tenants/Payment.php +++ b/app/Models/Tenants/Payment.php @@ -2,26 +2,287 @@ namespace App\Models\Tenants; +use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; /** + * 결제 모델 + * + * @property int $id + * @property int $subscription_id 구독 ID + * @property float $amount 결제 금액 + * @property string $payment_method 결제 수단 + * @property string|null $transaction_id PG 거래 ID + * @property Carbon|null $paid_at 결제일시 + * @property string $status 결제 상태 + * @property string|null $memo 메모 + * * @mixin IdeHelperPayment */ class Payment extends Model { use SoftDeletes; + // ========================================================================= + // 상수 정의 + // ========================================================================= + + /** 결제 상태 */ + public const STATUS_PENDING = 'pending'; + + public const STATUS_COMPLETED = 'completed'; + + public const STATUS_FAILED = 'failed'; + + public const STATUS_CANCELLED = 'cancelled'; + + public const STATUS_REFUNDED = 'refunded'; + + public const STATUSES = [ + self::STATUS_PENDING, + self::STATUS_COMPLETED, + self::STATUS_FAILED, + self::STATUS_CANCELLED, + self::STATUS_REFUNDED, + ]; + + /** 상태 라벨 */ + public const STATUS_LABELS = [ + self::STATUS_PENDING => '대기', + self::STATUS_COMPLETED => '완료', + self::STATUS_FAILED => '실패', + self::STATUS_CANCELLED => '취소', + self::STATUS_REFUNDED => '환불', + ]; + + /** 결제 수단 */ + public const METHOD_CARD = 'card'; + + public const METHOD_BANK = 'bank'; + + public const METHOD_VIRTUAL = 'virtual'; + + public const METHOD_CASH = 'cash'; + + public const METHOD_FREE = 'free'; + + public const PAYMENT_METHODS = [ + self::METHOD_CARD, + self::METHOD_BANK, + self::METHOD_VIRTUAL, + self::METHOD_CASH, + self::METHOD_FREE, + ]; + + /** 결제 수단 라벨 */ + public const METHOD_LABELS = [ + self::METHOD_CARD => '카드', + self::METHOD_BANK => '계좌이체', + self::METHOD_VIRTUAL => '가상계좌', + self::METHOD_CASH => '현금', + self::METHOD_FREE => '무료', + ]; + + // ========================================================================= + // 모델 설정 + // ========================================================================= + protected $fillable = [ - 'subscription_id', 'amount', 'payment_method', 'transaction_id', 'paid_at', 'status', 'memo', - ]; - - protected $dates = [ + 'subscription_id', + 'amount', + 'payment_method', + 'transaction_id', 'paid_at', + 'status', + 'memo', + 'created_by', + 'updated_by', + 'deleted_by', ]; - public function subscription() + protected $casts = [ + 'amount' => 'float', + 'paid_at' => 'datetime', + ]; + + protected $attributes = [ + 'status' => self::STATUS_PENDING, + 'payment_method' => self::METHOD_CARD, + ]; + + // ========================================================================= + // 스코프 + // ========================================================================= + + /** + * 완료된 결제만 + */ + public function scopeCompleted(Builder $query): Builder + { + return $query->where('status', self::STATUS_COMPLETED); + } + + /** + * 특정 상태 + */ + public function scopeOfStatus(Builder $query, string $status): Builder + { + return $query->where('status', $status); + } + + /** + * 특정 결제 수단 + */ + public function scopeOfMethod(Builder $query, string $method): Builder + { + return $query->where('payment_method', $method); + } + + /** + * 기간별 필터 + */ + public function scopeBetweenDates(Builder $query, ?string $startDate, ?string $endDate): Builder + { + if ($startDate) { + $query->where('paid_at', '>=', $startDate); + } + if ($endDate) { + $query->where('paid_at', '<=', $endDate.' 23:59:59'); + } + + return $query; + } + + // ========================================================================= + // 관계 + // ========================================================================= + + public function subscription(): BelongsTo { return $this->belongsTo(Subscription::class); } + + // ========================================================================= + // 접근자 + // ========================================================================= + + /** + * 상태 라벨 + */ + public function getStatusLabelAttribute(): string + { + return self::STATUS_LABELS[$this->status] ?? $this->status; + } + + /** + * 결제 수단 라벨 + */ + public function getPaymentMethodLabelAttribute(): string + { + return self::METHOD_LABELS[$this->payment_method] ?? $this->payment_method; + } + + /** + * 포맷된 금액 + */ + public function getFormattedAmountAttribute(): string + { + return number_format($this->amount).'원'; + } + + /** + * 결제 완료 여부 + */ + public function getIsCompletedAttribute(): bool + { + return $this->status === self::STATUS_COMPLETED; + } + + /** + * 환불 가능 여부 + */ + public function getIsRefundableAttribute(): bool + { + return $this->status === self::STATUS_COMPLETED; + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 결제 완료 처리 + */ + public function complete(?string $transactionId = null): bool + { + if ($this->status !== self::STATUS_PENDING) { + return false; + } + + $this->status = self::STATUS_COMPLETED; + $this->paid_at = now(); + + if ($transactionId) { + $this->transaction_id = $transactionId; + } + + return $this->save(); + } + + /** + * 결제 실패 처리 + */ + public function fail(?string $reason = null): bool + { + if ($this->status !== self::STATUS_PENDING) { + return false; + } + + $this->status = self::STATUS_FAILED; + + if ($reason) { + $this->memo = $reason; + } + + return $this->save(); + } + + /** + * 결제 취소 처리 + */ + public function cancel(?string $reason = null): bool + { + if (! in_array($this->status, [self::STATUS_PENDING, self::STATUS_COMPLETED])) { + return false; + } + + $this->status = self::STATUS_CANCELLED; + + if ($reason) { + $this->memo = $reason; + } + + return $this->save(); + } + + /** + * 환불 처리 + */ + public function refund(?string $reason = null): bool + { + if ($this->status !== self::STATUS_COMPLETED) { + return false; + } + + $this->status = self::STATUS_REFUNDED; + + if ($reason) { + $this->memo = $reason; + } + + return $this->save(); + } } diff --git a/app/Models/Tenants/Plan.php b/app/Models/Tenants/Plan.php index 383da16..8f587e9 100644 --- a/app/Models/Tenants/Plan.php +++ b/app/Models/Tenants/Plan.php @@ -2,18 +2,68 @@ namespace App\Models\Tenants; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; /** + * 요금제 모델 + * + * @property int $id + * @property string $name 요금제명 + * @property string $code 요금제 코드 + * @property string|null $description 설명 + * @property float $price 가격 + * @property string $billing_cycle 결제 주기 + * @property array|null $features 기능 목록 + * @property bool $is_active 활성 여부 + * * @mixin IdeHelperPlan */ class Plan extends Model { use SoftDeletes; + // ========================================================================= + // 상수 정의 + // ========================================================================= + + /** 결제 주기 */ + public const BILLING_MONTHLY = 'monthly'; + + public const BILLING_YEARLY = 'yearly'; + + public const BILLING_LIFETIME = 'lifetime'; + + public const BILLING_CYCLES = [ + self::BILLING_MONTHLY, + self::BILLING_YEARLY, + self::BILLING_LIFETIME, + ]; + + /** 결제 주기 라벨 */ + public const BILLING_CYCLE_LABELS = [ + self::BILLING_MONTHLY => '월간', + self::BILLING_YEARLY => '연간', + self::BILLING_LIFETIME => '평생', + ]; + + // ========================================================================= + // 모델 설정 + // ========================================================================= + protected $fillable = [ - 'name', 'code', 'description', 'price', 'billing_cycle', 'features', 'is_active', + 'name', + 'code', + 'description', + 'price', + 'billing_cycle', + 'features', + 'is_active', + 'created_by', + 'updated_by', + 'deleted_by', ]; protected $casts = [ @@ -22,8 +72,103 @@ class Plan extends Model 'price' => 'float', ]; - public function subscriptions() + protected $attributes = [ + 'is_active' => true, + 'billing_cycle' => self::BILLING_MONTHLY, + ]; + + // ========================================================================= + // 스코프 + // ========================================================================= + + /** + * 활성 요금제만 + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + /** + * 결제 주기별 필터 + */ + public function scopeOfCycle(Builder $query, string $cycle): Builder + { + return $query->where('billing_cycle', $cycle); + } + + // ========================================================================= + // 관계 + // ========================================================================= + + public function subscriptions(): HasMany { return $this->hasMany(Subscription::class); } + + // ========================================================================= + // 접근자 + // ========================================================================= + + /** + * 결제 주기 라벨 + */ + public function getBillingCycleLabelAttribute(): string + { + return self::BILLING_CYCLE_LABELS[$this->billing_cycle] ?? $this->billing_cycle; + } + + /** + * 포맷된 가격 + */ + public function getFormattedPriceAttribute(): string + { + return number_format($this->price).'원'; + } + + /** + * 활성 구독 수 + */ + public function getActiveSubscriptionCountAttribute(): int + { + return $this->subscriptions() + ->where('status', Subscription::STATUS_ACTIVE) + ->count(); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 월 환산 가격 계산 + */ + public function getMonthlyPrice(): float + { + return match ($this->billing_cycle) { + self::BILLING_YEARLY => round($this->price / 12, 2), + self::BILLING_LIFETIME => 0, + default => $this->price, + }; + } + + /** + * 연 환산 가격 계산 + */ + public function getYearlyPrice(): float + { + return match ($this->billing_cycle) { + self::BILLING_MONTHLY => $this->price * 12, + self::BILLING_LIFETIME => 0, + default => $this->price, + }; + } + + /** + * 특정 기능 포함 여부 + */ + public function hasFeature(string $feature): bool + { + return in_array($feature, $this->features ?? [], true); + } } diff --git a/app/Models/Tenants/Subscription.php b/app/Models/Tenants/Subscription.php index 8031a0c..f826633 100644 --- a/app/Models/Tenants/Subscription.php +++ b/app/Models/Tenants/Subscription.php @@ -2,36 +2,312 @@ namespace App\Models\Tenants; +use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; /** + * 구독 모델 + * + * @property int $id + * @property int $tenant_id 테넌트 ID + * @property int $plan_id 요금제 ID + * @property Carbon $started_at 시작일 + * @property Carbon|null $ended_at 종료일 + * @property string $status 상태 + * @property Carbon|null $cancelled_at 취소일 + * @property string|null $cancel_reason 취소 사유 + * * @mixin IdeHelperSubscription */ class Subscription extends Model { use SoftDeletes; + // ========================================================================= + // 상수 정의 + // ========================================================================= + + /** 구독 상태 */ + public const STATUS_ACTIVE = 'active'; + + public const STATUS_CANCELLED = 'cancelled'; + + public const STATUS_EXPIRED = 'expired'; + + public const STATUS_PENDING = 'pending'; + + public const STATUS_SUSPENDED = 'suspended'; + + public const STATUSES = [ + self::STATUS_ACTIVE, + self::STATUS_CANCELLED, + self::STATUS_EXPIRED, + self::STATUS_PENDING, + self::STATUS_SUSPENDED, + ]; + + /** 상태 라벨 */ + public const STATUS_LABELS = [ + self::STATUS_ACTIVE => '활성', + self::STATUS_CANCELLED => '취소됨', + self::STATUS_EXPIRED => '만료됨', + self::STATUS_PENDING => '대기', + self::STATUS_SUSPENDED => '일시정지', + ]; + + // ========================================================================= + // 모델 설정 + // ========================================================================= + protected $fillable = [ - 'tenant_id', 'plan_id', 'started_at', 'ended_at', 'status', + 'tenant_id', + 'plan_id', + 'started_at', + 'ended_at', + 'status', + 'cancelled_at', + 'cancel_reason', + 'created_by', + 'updated_by', + 'deleted_by', ]; - protected $dates = [ - 'started_at', 'ended_at', + protected $casts = [ + 'started_at' => 'datetime', + 'ended_at' => 'datetime', + 'cancelled_at' => 'datetime', ]; - public function tenant() + protected $attributes = [ + 'status' => self::STATUS_PENDING, + ]; + + // ========================================================================= + // 스코프 + // ========================================================================= + + /** + * 활성 구독만 + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('status', self::STATUS_ACTIVE); + } + + /** + * 유효한 구독 (활성 + 미만료) + */ + public function scopeValid(Builder $query): Builder + { + return $query->where('status', self::STATUS_ACTIVE) + ->where(function ($q) { + $q->whereNull('ended_at') + ->orWhere('ended_at', '>', now()); + }); + } + + /** + * 만료 예정 (N일 이내) + */ + public function scopeExpiringWithin(Builder $query, int $days): Builder + { + return $query->where('status', self::STATUS_ACTIVE) + ->whereNotNull('ended_at') + ->where('ended_at', '<=', now()->addDays($days)) + ->where('ended_at', '>', now()); + } + + /** + * 특정 테넌트 + */ + public function scopeForTenant(Builder $query, int $tenantId): Builder + { + return $query->where('tenant_id', $tenantId); + } + + // ========================================================================= + // 관계 + // ========================================================================= + + public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); } - public function plan() + public function plan(): BelongsTo { return $this->belongsTo(Plan::class); } - public function payments() + public function payments(): HasMany { return $this->hasMany(Payment::class); } + + // ========================================================================= + // 접근자 + // ========================================================================= + + /** + * 상태 라벨 + */ + public function getStatusLabelAttribute(): string + { + return self::STATUS_LABELS[$this->status] ?? $this->status; + } + + /** + * 만료 여부 + */ + public function getIsExpiredAttribute(): bool + { + if (! $this->ended_at) { + return false; + } + + return $this->ended_at->isPast(); + } + + /** + * 남은 일수 + */ + public function getRemainingDaysAttribute(): ?int + { + if (! $this->ended_at) { + return null; // 무제한 + } + + if ($this->is_expired) { + return 0; + } + + return now()->diffInDays($this->ended_at, false); + } + + /** + * 유효 여부 + */ + public function getIsValidAttribute(): bool + { + return $this->status === self::STATUS_ACTIVE && ! $this->is_expired; + } + + /** + * 총 결제 금액 + */ + public function getTotalPaidAttribute(): float + { + return (float) $this->payments() + ->where('status', Payment::STATUS_COMPLETED) + ->sum('amount'); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 구독 활성화 + */ + public function activate(): bool + { + if ($this->status !== self::STATUS_PENDING) { + return false; + } + + $this->status = self::STATUS_ACTIVE; + $this->started_at = $this->started_at ?? now(); + + // 종료일 계산 (요금제 주기에 따라) + if (! $this->ended_at && $this->plan) { + $this->ended_at = match ($this->plan->billing_cycle) { + Plan::BILLING_MONTHLY => $this->started_at->copy()->addMonth(), + Plan::BILLING_YEARLY => $this->started_at->copy()->addYear(), + Plan::BILLING_LIFETIME => null, + default => $this->started_at->copy()->addMonth(), + }; + } + + return $this->save(); + } + + /** + * 구독 갱신 + */ + public function renew(?Carbon $newEndDate = null): bool + { + if ($this->status !== self::STATUS_ACTIVE) { + return false; + } + + if ($newEndDate) { + $this->ended_at = $newEndDate; + } elseif ($this->plan) { + $baseDate = $this->ended_at ?? now(); + $this->ended_at = match ($this->plan->billing_cycle) { + Plan::BILLING_MONTHLY => $baseDate->copy()->addMonth(), + Plan::BILLING_YEARLY => $baseDate->copy()->addYear(), + Plan::BILLING_LIFETIME => null, + default => $baseDate->copy()->addMonth(), + }; + } + + return $this->save(); + } + + /** + * 구독 취소 + */ + public function cancel(?string $reason = null): bool + { + if (! in_array($this->status, [self::STATUS_ACTIVE, self::STATUS_PENDING])) { + return false; + } + + $this->status = self::STATUS_CANCELLED; + $this->cancelled_at = now(); + $this->cancel_reason = $reason; + + return $this->save(); + } + + /** + * 구독 일시정지 + */ + public function suspend(): bool + { + if ($this->status !== self::STATUS_ACTIVE) { + return false; + } + + $this->status = self::STATUS_SUSPENDED; + + return $this->save(); + } + + /** + * 구독 재개 + */ + public function resume(): bool + { + if ($this->status !== self::STATUS_SUSPENDED) { + return false; + } + + $this->status = self::STATUS_ACTIVE; + + return $this->save(); + } + + /** + * 취소 가능 여부 + */ + public function isCancellable(): bool + { + return in_array($this->status, [self::STATUS_ACTIVE, self::STATUS_PENDING]); + } } diff --git a/app/Services/PaymentService.php b/app/Services/PaymentService.php new file mode 100644 index 0000000..12ae5c3 --- /dev/null +++ b/app/Services/PaymentService.php @@ -0,0 +1,276 @@ +tenantId(); + + // 테넌트의 구독에 속한 결제만 조회 + $subscriptionIds = Subscription::query() + ->where('tenant_id', $tenantId) + ->pluck('id'); + + $query = Payment::query() + ->whereIn('subscription_id', $subscriptionIds) + ->with(['subscription.plan:id,name,code']); + + // 상태 필터 + if (! empty($params['status'])) { + $query->ofStatus($params['status']); + } + + // 결제 수단 필터 + if (! empty($params['payment_method'])) { + $query->ofMethod($params['payment_method']); + } + + // 날짜 범위 필터 + $query->betweenDates( + $params['start_date'] ?? null, + $params['end_date'] ?? null + ); + + // 검색 (거래 ID, 메모) + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('transaction_id', 'like', "%{$search}%") + ->orWhere('memo', 'like', "%{$search}%"); + }); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'created_at'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 결제 상세 + */ + public function show(int $id): Payment + { + $tenantId = $this->tenantId(); + + // 테넌트 검증을 위해 구독 통해 조회 + $subscriptionIds = Subscription::query() + ->where('tenant_id', $tenantId) + ->pluck('id'); + + return Payment::query() + ->whereIn('subscription_id', $subscriptionIds) + ->with(['subscription.plan']) + ->findOrFail($id); + } + + /** + * 결제 요약 통계 + */ + public function summary(array $params = []): array + { + $tenantId = $this->tenantId(); + + $subscriptionIds = Subscription::query() + ->where('tenant_id', $tenantId) + ->pluck('id'); + + $query = Payment::query() + ->whereIn('subscription_id', $subscriptionIds); + + // 날짜 범위 필터 + if (! empty($params['start_date']) || ! empty($params['end_date'])) { + $query->betweenDates( + $params['start_date'] ?? null, + $params['end_date'] ?? null + ); + } + + $stats = $query->selectRaw(' + COUNT(*) as total_count, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as completed_count, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending_count, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as failed_count, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as cancelled_count, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as refunded_count, + SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as total_completed_amount, + SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as total_refunded_amount + ', [ + Payment::STATUS_COMPLETED, + Payment::STATUS_PENDING, + Payment::STATUS_FAILED, + Payment::STATUS_CANCELLED, + Payment::STATUS_REFUNDED, + Payment::STATUS_COMPLETED, + Payment::STATUS_REFUNDED, + ])->first(); + + // 결제 수단별 집계 + $byMethod = Payment::query() + ->whereIn('subscription_id', $subscriptionIds) + ->completed() + ->selectRaw('payment_method, COUNT(*) as count, SUM(amount) as total_amount') + ->groupBy('payment_method') + ->get() + ->keyBy('payment_method') + ->map(fn ($item) => [ + 'count' => (int) $item->count, + 'total_amount' => (float) $item->total_amount, + ]) + ->toArray(); + + return [ + 'total_count' => (int) $stats->total_count, + 'completed_count' => (int) $stats->completed_count, + 'pending_count' => (int) $stats->pending_count, + 'failed_count' => (int) $stats->failed_count, + 'cancelled_count' => (int) $stats->cancelled_count, + 'refunded_count' => (int) $stats->refunded_count, + 'total_completed_amount' => (float) $stats->total_completed_amount, + 'total_refunded_amount' => (float) $stats->total_refunded_amount, + 'net_amount' => (float) ($stats->total_completed_amount - $stats->total_refunded_amount), + 'by_method' => $byMethod, + ]; + } + + // ========================================================================= + // 결제 처리 + // ========================================================================= + + /** + * 결제 생성 (수동) + */ + public function store(array $data): Payment + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 구독 확인 + $subscription = Subscription::query() + ->where('tenant_id', $tenantId) + ->findOrFail($data['subscription_id']); + + return DB::transaction(function () use ($data, $subscription, $userId) { + $payment = Payment::create([ + 'subscription_id' => $subscription->id, + 'amount' => $data['amount'], + 'payment_method' => $data['payment_method'] ?? Payment::METHOD_CARD, + 'transaction_id' => $data['transaction_id'] ?? null, + 'status' => Payment::STATUS_PENDING, + 'memo' => $data['memo'] ?? null, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + // 자동 완료 처리 + if (! empty($data['auto_complete']) && $data['auto_complete']) { + $payment->complete($data['transaction_id'] ?? null); + } + + return $payment->fresh(['subscription.plan']); + }); + } + + /** + * 결제 완료 처리 + */ + public function complete(int $id, ?string $transactionId = null): Payment + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $subscriptionIds = Subscription::query() + ->where('tenant_id', $tenantId) + ->pluck('id'); + + $payment = Payment::query() + ->whereIn('subscription_id', $subscriptionIds) + ->findOrFail($id); + + if (! $payment->complete($transactionId)) { + throw new BadRequestHttpException(__('error.payment.not_completable')); + } + + $payment->updated_by = $userId; + $payment->save(); + + // 구독이 대기 중이면 활성화 + $subscription = $payment->subscription; + if ($subscription->status === Subscription::STATUS_PENDING) { + $subscription->activate(); + } + + return $payment->fresh(['subscription.plan']); + } + + /** + * 결제 취소 + */ + public function cancel(int $id, ?string $reason = null): Payment + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $subscriptionIds = Subscription::query() + ->where('tenant_id', $tenantId) + ->pluck('id'); + + $payment = Payment::query() + ->whereIn('subscription_id', $subscriptionIds) + ->findOrFail($id); + + if (! $payment->cancel($reason)) { + throw new BadRequestHttpException(__('error.payment.not_cancellable')); + } + + $payment->updated_by = $userId; + $payment->save(); + + return $payment->fresh(['subscription.plan']); + } + + /** + * 환불 처리 + */ + public function refund(int $id, ?string $reason = null): Payment + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $subscriptionIds = Subscription::query() + ->where('tenant_id', $tenantId) + ->pluck('id'); + + $payment = Payment::query() + ->whereIn('subscription_id', $subscriptionIds) + ->findOrFail($id); + + if (! $payment->refund($reason)) { + throw new BadRequestHttpException(__('error.payment.not_refundable')); + } + + $payment->updated_by = $userId; + $payment->save(); + + return $payment->fresh(['subscription.plan']); + } +} diff --git a/app/Services/PlanService.php b/app/Services/PlanService.php new file mode 100644 index 0000000..921be34 --- /dev/null +++ b/app/Services/PlanService.php @@ -0,0 +1,164 @@ +where('is_active', (bool) $params['is_active']); + } + + // 결제 주기 필터 + if (! empty($params['billing_cycle'])) { + $query->ofCycle($params['billing_cycle']); + } + + // 검색 (이름, 코드, 설명) + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('code', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'price'; + $sortDir = $params['sort_dir'] ?? 'asc'; + $query->orderBy($sortBy, $sortDir); + + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 활성 요금제 목록 (공개용) + */ + public function active(): Collection + { + return Plan::active() + ->orderBy('price', 'asc') + ->get(); + } + + /** + * 요금제 상세 + */ + public function show(int $id): Plan + { + return Plan::query() + ->withCount(['subscriptions as active_subscriptions_count' => function ($q) { + $q->where('status', 'active'); + }]) + ->findOrFail($id); + } + + // ========================================================================= + // 요금제 생성/수정/삭제 + // ========================================================================= + + /** + * 요금제 생성 + */ + public function store(array $data): Plan + { + $userId = $this->apiUserId(); + + return Plan::create([ + 'name' => $data['name'], + 'code' => $data['code'], + 'description' => $data['description'] ?? null, + 'price' => $data['price'], + 'billing_cycle' => $data['billing_cycle'] ?? Plan::BILLING_MONTHLY, + 'features' => $data['features'] ?? null, + 'is_active' => $data['is_active'] ?? true, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + } + + /** + * 요금제 수정 + */ + public function update(int $id, array $data): Plan + { + $userId = $this->apiUserId(); + + $plan = Plan::findOrFail($id); + + $plan->fill([ + 'name' => $data['name'] ?? $plan->name, + 'code' => $data['code'] ?? $plan->code, + 'description' => $data['description'] ?? $plan->description, + 'price' => $data['price'] ?? $plan->price, + 'billing_cycle' => $data['billing_cycle'] ?? $plan->billing_cycle, + 'features' => $data['features'] ?? $plan->features, + 'is_active' => $data['is_active'] ?? $plan->is_active, + 'updated_by' => $userId, + ]); + + $plan->save(); + + return $plan->fresh(); + } + + /** + * 요금제 삭제 + */ + public function destroy(int $id): bool + { + $userId = $this->apiUserId(); + + $plan = Plan::findOrFail($id); + + // 활성 구독이 있으면 삭제 불가 + $activeCount = $plan->subscriptions() + ->whereIn('status', ['active', 'pending']) + ->count(); + + if ($activeCount > 0) { + throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException( + __('error.plan.has_active_subscriptions') + ); + } + + $plan->deleted_by = $userId; + $plan->save(); + $plan->delete(); + + return true; + } + + /** + * 요금제 활성/비활성 토글 + */ + public function toggle(int $id): Plan + { + $userId = $this->apiUserId(); + + $plan = Plan::findOrFail($id); + $plan->is_active = ! $plan->is_active; + $plan->updated_by = $userId; + $plan->save(); + + return $plan; + } +} diff --git a/app/Services/SubscriptionService.php b/app/Services/SubscriptionService.php new file mode 100644 index 0000000..59b76db --- /dev/null +++ b/app/Services/SubscriptionService.php @@ -0,0 +1,297 @@ +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']); + } +} diff --git a/app/Swagger/v1/PaymentApi.php b/app/Swagger/v1/PaymentApi.php new file mode 100644 index 0000000..73cb85c --- /dev/null +++ b/app/Swagger/v1/PaymentApi.php @@ -0,0 +1,364 @@ +whereNumber('id')->name('v1.loans.settle'); }); + // Plan API (요금제 관리) + Route::prefix('plans')->group(function () { + Route::get('', [PlanController::class, 'index'])->name('v1.plans.index'); + Route::post('', [PlanController::class, 'store'])->name('v1.plans.store'); + Route::get('/active', [PlanController::class, 'active'])->name('v1.plans.active'); + Route::get('/{id}', [PlanController::class, 'show'])->whereNumber('id')->name('v1.plans.show'); + Route::put('/{id}', [PlanController::class, 'update'])->whereNumber('id')->name('v1.plans.update'); + Route::delete('/{id}', [PlanController::class, 'destroy'])->whereNumber('id')->name('v1.plans.destroy'); + Route::patch('/{id}/toggle', [PlanController::class, 'toggle'])->whereNumber('id')->name('v1.plans.toggle'); + }); + + // Subscription API (구독 관리) + Route::prefix('subscriptions')->group(function () { + 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('/{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'); + Route::post('/{id}/suspend', [SubscriptionController::class, 'suspend'])->whereNumber('id')->name('v1.subscriptions.suspend'); + Route::post('/{id}/resume', [SubscriptionController::class, 'resume'])->whereNumber('id')->name('v1.subscriptions.resume'); + }); + + // Payment API (결제 관리) + Route::prefix('payments')->group(function () { + Route::get('', [PaymentController::class, 'index'])->name('v1.payments.index'); + Route::post('', [PaymentController::class, 'store'])->name('v1.payments.store'); + Route::get('/summary', [PaymentController::class, 'summary'])->name('v1.payments.summary'); + Route::get('/{id}', [PaymentController::class, 'show'])->whereNumber('id')->name('v1.payments.show'); + 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'); + }); + // Sale API (매출 관리) Route::prefix('sales')->group(function () { Route::get('', [SaleController::class, 'index'])->name('v1.sales.index');