- Auditable 트레이트 신규 생성 (bootAuditable 패턴) - creating: created_by/updated_by 자동 채우기 - updating: updated_by 자동 채우기 - deleting: deleted_by 채우기 + saveQuietly() - created/updated/deleted: audit_logs 자동 기록 - 기존 AuditLogger 패턴과 동일한 try/catch 조용한 실패 - 변경된 필드만 before/after 기록 (updated 이벤트) - auditExclude 프로퍼티로 모델별 제외 필드 설정 가능 - 제외 대상: Attendance, StockTransaction, TodayIssue 등 고빈도/시스템 모델 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
315 lines
7.9 KiB
PHP
315 lines
7.9 KiB
PHP
<?php
|
|
|
|
namespace App\Models\Tenants;
|
|
|
|
use App\Traits\Auditable;
|
|
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 Auditable, 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',
|
|
'cancelled_at',
|
|
'cancel_reason',
|
|
'created_by',
|
|
'updated_by',
|
|
'deleted_by',
|
|
];
|
|
|
|
protected $casts = [
|
|
'started_at' => 'datetime',
|
|
'ended_at' => 'datetime',
|
|
'cancelled_at' => 'datetime',
|
|
];
|
|
|
|
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(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Plan::class);
|
|
}
|
|
|
|
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]);
|
|
}
|
|
}
|