HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/ai.naniguide.com/app/Model/Subscription.php
<?php

namespace App\Model;

use App\ActivityLog\Activities\SubscriptionEvent;
use Illuminate\Database\Eloquent\Model;
use Exception;
use App\Library\Traits\HasUid;
use Carbon\Carbon;
use App\Library\Facades\SubscriptionFacade;
use App\Library\RemoteReconcile\ReconcileContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use App\Library\Facades\Hook;

class Subscription extends Model
{
    use HasUid;
    protected $connection = 'mysql';

    public const STATUS_NEW = 'new';
    public const STATUS_ACTIVE = 'active';
    public const STATUS_ENDED = 'ended';           //
    public const STATUS_CANCELLED = 'cancelled';   // Customer chooses "End now"
    public const STATUS_TERMINATED = 'terminated'; // Admin terminates a subscription

    public const REMOTE_RECONCILE_STATUS_NOT_FOUND = 'remote_subscription_not_found';
    public const REMOTE_RECONCILE_STATUS_ALREADY_CANCELLED = 'remote_subscription_already_cancelled';
    public const REMOTE_RECONCILE_STATUS_CANCEL_FAILED = 'remote_cancel_failed';
    public const REMOTE_RECONCILE_STATUS_LOCAL_COMMIT_FAILED_AFTER_REMOTE_SUCCESS = 'local_commit_failed_after_remote_success';
    public const REMOTE_RECONCILE_STATUS_STATE_MISMATCH = 'remote_state_mismatch';
    public const REMOTE_RECONCILE_STATUS_SYNC_FAILED = 'remote_sync_failed';

    public const REMOTE_CANCEL_OUTCOME_SUCCESS = 'success';

    /**
     * The attributes that are not mass assignable.
     *
     * @var array
     */
    protected $guarded = [];

    protected $casts = [
        'cancelled_at' => 'datetime',
        'current_period_ends_at' => 'datetime',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
        'terminated_at' => 'datetime',
        'last_synced_at' => 'datetime',
        'remote_metadata' => 'array',
        'remote_reconcile_context' => 'array',
        'remote_reconcile_updated_at' => 'datetime',
    ];

    /**
     * Indicates if the plan change should be prorated.
     *
     * @var bool
     */
    protected $prorate = true;

    /**
     * Associations.
     *
     * @var object | collect
     */
    public function plan()
    {
        // @todo dependency injection
        return $this->belongsTo('\App\Model\Plan');
    }

    public function scopeNew($query)
    {
        return $query->whereIn('status', [ self::STATUS_NEW ]);
    }

    public function scopeNewOrActive($query)
    {
        return $query->whereIn('status', [ self::STATUS_ACTIVE, self::STATUS_NEW ]);
    }

    /**
     * Subscriptions eligible for remote sync: new, active, or cancelled (still running until period end).
     * Excludes ended and terminated which are permanently done.
     */
    public function scopeSyncable($query)
    {
        return $query->whereIn('status', [self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_CANCELLED]);
    }

    /**
     * Get the user that owns the subscription.
     *
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function customer()
    {
        // @todo dependency injection
        return $this->belongsTo('\App\Model\Customer');
    }

    public function remoteGateway()
    {
        return $this->belongsTo(PaymentGateway::class, 'remote_gateway_id');
    }

    public function hasRemoteSubscription(): bool
    {
        return !empty($this->remote_subscription_id);
    }

    /**
     * Get remote_metadata as a guaranteed array.
     * Handles legacy double-encoded JSON strings gracefully.
     */
    public function getRemoteMetadataArray(): array
    {
        $meta = $this->remote_metadata;

        if (is_array($meta)) {
            return $meta;
        }

        if (is_string($meta)) {
            $decoded = json_decode($meta, true);
            return is_array($decoded) ? $decoded : [];
        }

        return [];
    }

    public function getRemoteReconcileContextArray(): array
    {
        $context = $this->remote_reconcile_context;

        if (is_array($context)) {
            return $context;
        }

        if (is_string($context)) {
            $decoded = json_decode($context, true);
            return is_array($decoded) ? $decoded : [];
        }

        return [];
    }

    public function setRemoteReconcileIssue(string $status, array $context = []): void
    {
        $this->remote_reconcile_status = $status;
        $this->remote_reconcile_context = ReconcileContext::sanitize($context);
        $this->remote_reconcile_updated_at = now();
    }

    public function clearRemoteReconcileIssue(): void
    {
        $this->remote_reconcile_status = null;
        $this->remote_reconcile_context = null;
        $this->remote_reconcile_updated_at = null;
    }

    public function saveRemoteReconcileIssue(string $status, array $context = []): void
    {
        $this->setRemoteReconcileIssue($status, $context);
        $this->save();
    }

    public function clearRemoteReconcileIssueIfSetAndSave(): bool
    {
        if (
            $this->remote_reconcile_status === null &&
            $this->remote_reconcile_context === null &&
            $this->remote_reconcile_updated_at === null
        ) {
            return false;
        }

        $this->clearRemoteReconcileIssue();
        $this->save();

        return true;
    }

    public function buildRemoteReconcileContext(string $action, $gateway = null, array $context = []): array
    {
        return ReconcileContext::sanitize(array_merge([
            'action' => $action,
            'remote_subscription_id' => $this->remote_subscription_id,
            'gateway_id' => $gateway->id ?? null,
            'gateway_name' => $gateway->name ?? null,
            'gateway_type' => $gateway->type ?? null,
            'attempted_at' => now()->toIso8601String(),
        ], $context));
    }

    // Throwable-inspection helpers (guessRemoteReconcileStatusFromException, extractThrowableContext,
    // sanitizeRemoteReconcileContext) moved to App\Library\RemoteReconcile\ReconcileContext.

    // Remote API methods (fetchRemoteSubscription, setRemoteSubscription, activateFromRemote,
    // updateRemoteSubscriptionPlan, findRemotePlanMapping) moved to SubscriptionManagementService.

    public function orderItems()
    {
        return $this->hasMany(OrderItem::class, 'subscription_id');
    }

    /**
     * Subscription only has one new invoice.
     *
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function getUnpaidOrder()
    {
        $orderItem = $this->orderItems()
            ->unpaid()
            ->orderBy('created_at', 'desc')
            ->first();

        return $orderItem ? $orderItem->order : null;
    }

    public function scopeActive($query)
    {
        $query->where('status', self::STATUS_ACTIVE);
    }

    public function scopeEnded($query)
    {
        $query->where('status', self::STATUS_ENDED);
    }

    public function scopeCancelled($query)
    {
        $query->where('status', self::STATUS_CANCELLED);
    }

    public function scopeCancelledOrEdned($query)
    {
        $query->whereIn('status', [self::STATUS_CANCELLED, self::STATUS_ENDED]);
    }

    /**
     * Get last invoice.
     *
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function getItsOnlyUnpaidInitOrder()
    {
        if (!$this->isNew()) {
            throw new \Exception('Method getItsOnlyUnpaidInitOrder() only use for NEW subscription');
        }

        $query = $this->orderItems()
            ->newSubscription()
            ->unpaid();

        // new sub luôn phải có duy nhất 1 invoice
        if ($query->count() > 1) {
            throw new \Exception('New Subscription must have only one unpaid TYPE_NEW_SUBSCRIPTION invoice!');
        }

        $orderItem = $query->first();

        return $orderItem ? $orderItem->order : $orderItem;
    }

    /**
     * Get renew invoice.
     *
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function getItsOnlyUnpaidChangePlanOrder()
    {
        if (!$this->isActive()) {
            throw new \Exception('Method getItsOnlyUnpaidChangePlanOrder() only use for ACTIVE subscription!');
        }

        $query = $this->orderItems()
            ->changePlan()
            ->unpaid();

        if ($query->count() > 1) {
            throw new \Exception('Somehow sub has more than one unpaid change plan invoice!');
        }

        $orderItem = $query->first();

        return $orderItem ? $orderItem->order : $orderItem;
    }

    // Order-creation methods (createNewSubscriptionOrder, createRenewOrder, createChangePlanOrder)
    // moved to App\Services\Subscription\SubscriptionManagementService.

    public function getAutoBillingDate()
    {
        return $this->current_period_ends_at->copy()->subDays(\App\Model\Setting::get('subscription.auto_billing_period'));
    }

    /**
     * reach due date.
     */
    public function isBillingPeriod()
    {
        return Carbon::now()->greaterThanOrEqualTo(
            $this->getAutoBillingDate()
        );
    }

    // changePlan() moved to SubscriptionManagementService::applyPlanChange.

    public function paymentIntents()
    {
        return \App\Model\PaymentIntent::whereHas('invoice', function (Builder $query) {
            $query->whereHas('order', function ($subQuery) {
                $subQuery->whereIn('id', function ($subSubQuery) {
                    $subSubQuery->select('order_id')
                        ->from('order_items')
                        ->where('subscription_id', $this->id);
                });
            });
        });
    }

    public function orders()
    {
        return Order::whereHas('orderItems', function (Builder $query) {
            $query->where('subscription_id', $this->id);
        });
    }

    public function isRecurring()
    {
        return $this->is_recurring;
    }

    /**
     * Determine if the subscription is active.
     *
     * @return bool
     */
    public function isActive()
    {
        return $this->status == self::STATUS_ACTIVE;
    }

    /**
     * Determine if the subscription is active.
     *
     * @return bool
     */
    public function isNew()
    {
        return $this->status == self::STATUS_NEW;
    }

    /**
     * Determine if the subscription is ended.
     *
     * @return bool
     */
    public function isEnded()
    {
        return $this->status == self::STATUS_ENDED;
    }

    public function isCancelled()
    {
        return $this->status == self::STATUS_CANCELLED;
    }

    public function isTerminated()
    {
        return $this->status == self::STATUS_TERMINATED;
    }


    // activate() moved to SubscriptionManagementService::activate.

    /**
     * Next one period to subscription.
     *
     * @param  Gateway    $gateway
     * @return Boolean
     */
    public function periodStartAt()
    {
        $startAt = $this->current_period_ends_at;
        $interval = $this->plan->frequency_unit;
        $intervalCount = $this->plan->frequency_amount;

        switch ($interval) {
            case 'month':
                $startAt = $startAt->subMonthsNoOverflow($intervalCount);
                break;
            case 'day':
                $startAt = $startAt->subDay($intervalCount);
                // no break
            case 'week':
                $startAt = $startAt->subWeek($intervalCount);
                break;
            case 'year':
                $startAt = $startAt->subYearsNoOverflow($intervalCount);
                break;
            default:
                $startAt = null;
        }

        return $startAt;
    }

    /**
     * Check if subscription is expired.
     *
     * @param  Int  $subscriptionId
     * @return date
     */
    public function isExpired()
    {
        // Get the current datetime
        $now = Carbon::now();

        // Check if $now is Greater or Equal to...
        return $now->gte($this->current_period_ends_at);
    }

    /**
     * Subscription transactions.
     *
     * @return array
     */
    public function getLogs()
    {
        return \App\Model\ActivityLog::where('subject_type', 'subscription')
            ->where('subject_id', $this->id)
            ->orderBy('created_at', 'desc')
            ->orderBy('id', 'desc')
            ->get();
    }

    public function getItsOnlyUnpaidRenewOrder()
    {
        if (!$this->isActive()) {
            throw new \Exception('Method getItsOnlyUnpaidRenewOrder() only use for ACTIVE subscription!');
        }

        $query = $this->orderItems()
            ->unpaid()
            ->renew();

        if ($query->count() > 1) {
            throw new \Exception('Somehow sub has more than one unpaid renew order item!');
        }

        $orderItem = $query->first();

        return $orderItem ? $orderItem->order : $orderItem;
    }

    // Lifecycle methods (cancelNow, end, disableRecurring, enableRecurring, endIfExpired,
    // checkAndCreateRenewOrder, terminate) moved to App\Services\Subscription\SubscriptionManagementService.
    // Model holds data + state checks + simple accessors only.

    // renew() moved to SubscriptionManagementService::renew.

    public function isExpiring()
    {
        if (!$this->current_period_ends_at) {
            return false;
        }

        // check if recurring accur
        if (Carbon::now()->greaterThanOrEqualTo($this->current_period_ends_at->copy()->subDays(Setting::get('subscription.expiring_period')))) {
            return true;
        }

        return false;
    }

    // canRenewPlan/canAutoRenewFreePlan/getPlanName/getCustomerName/getPeriodEndsAt/
    // getTrialPeriodEndsAt/nextPeriod removed — call $sub->plan->canRenew() /
    // $sub->plan->name / $sub->plan->periodEndsAt() / $sub->plan->trialPeriodEndsAt() /
    // $sub->customer->displayName() directly.

    // calcChangePlan() moved to SubscriptionManagementService::calcChangePlan.

    // createNewSubscription() / deleteAndCleanup() moved to SubscriptionManagementService.
    // Per-subscription credit + rate-limit access lives on
    // App\Services\Plans\Credits\CreditsService (use trackerFor / remainingLive
    // / canUse / consume / grant) and App\Services\Plans\RateLimits\RateLimitsService.
    // Hot-tracker file path is declared by CreditKey::trackerBasename — single
    // source of truth, lifecycle-driven init via SubscriptionLifecycle.

    public function apiKey()
    {
        return $this->hasOne(ApiKey::class);
    }
}