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);
}
}