uuid)) { $model->uuid = md5(time() . wp_generate_uuid4()); } }); } public function meta() { return $this->hasMany(SubscriptionMeta::class, 'subscription_id', 'id'); } public function customer(): BelongsTo { return $this->belongsTo(Customer::class, 'customer_id', 'id'); } public function product(): BelongsTo { return $this->belongsTo(Product::class, 'product_id', 'ID'); } public function variation(): BelongsTo { return $this->belongsTo(ProductVariation::class, 'variation_id'); } public function labels(): MorphMany { return $this->morphMany(LabelRelationship::class, 'labelable'); } public function license(): ?HasOne { if (!class_exists(License::class)) { return null; } return $this->hasOne(License::class, 'subscription_id', 'id'); } public function licenses(): ?HasMany { if (!class_exists(License::class)) { return null; } return $this->hasMany(License::class, 'subscription_id', 'id'); } public function transactions(): HasMany { return $this->hasMany(OrderTransaction::class, 'subscription_id', 'id'); } public function billing_addresses(): HasMany { return $this->hasMany(CustomerAddresses::class, 'customer_id', 'customer_id')->where('type', 'billing'); } public function getConfigAttribute($value) { if (is_string($value)) { $decoded = json_decode($value, true); return $decoded ?: $value; } return $value ?: []; } public function setConfigAttribute($value) { if (is_array($value)) { $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } else { $value = '[]'; } $this->attributes['config'] = $value; } public function getUrlAttribute($value) { return apply_filters('fluent_cart/subscription/url_' . $this->current_payment_method, '', [ 'vendor_subscription_id' => $this->vendor_subscription_id, 'payment_mode' => (new StoreSettings())->get('order_mode'), 'subscription' => $this ]); } // use this to override the status of the subscription for any custom use case /** * current use case: If the orignal plan(product variation) has no trial days but the subscription status is 'trialing' * it can happens upon discount applied / proration on plan change, * use overriden status to show the correct status for customer */ public function getOverriddenStatusAttribute($value) { $variation = ProductVariation::find($this->variation_id); if (Arr::get($this->config, 'is_trial_days_simulated', 'no') == 'yes' && $this->status == Status::SUBSCRIPTION_TRIALING) { return Status::SUBSCRIPTION_ACTIVE; } if (Arr::get($this->config, 'is_trial_days_simulated', 'no') !== 'yes' && $this->status == Status::SUBSCRIPTION_ACTIVE && $this->trial_days && (strtotime($this->created_at) + ($this->trial_days * 86400)) > time()) { return Status::SUBSCRIPTION_TRIALING; } return $this->status; } public function getBillingInfoAttribute($value) { $billingInfo = ''; $metaKey = 'active_payment_method'; $meta = $this->meta->where('meta_key', $metaKey)->first(); $billingInfo = $meta ? (is_string($meta->meta_value) ? json_decode($meta->meta_value, true) : $meta->meta_value) : []; return $billingInfo; } public function getPaymentMethodText() { $info = Arr::get($this->billingInfo, 'details'); if (Arr::get($info, 'brand') && Arr::get($info, 'last_4')) { return sprintf('%1$s ***%2$s', esc_html($info['brand']), esc_html($info['last_4'])); } return Arr::get($info, 'method', ''); } public function product_detail(): BelongsTo { return $this->belongsTo(ProductDetail::class, 'variation_id', 'id'); } public function order(): BelongsTo { return $this->belongsTo(Order::class, 'parent_order_id', 'id'); } /** * Get the currency for the subscription * * @return string */ public function getCurrencyAttribute(): string { $currency = ''; if (empty($this->config)) { // get from store settings $currency = CurrencySettings::get('currency'); return strtoupper($currency); } $definedCurrency = Arr::get($this->config, 'currency', ''); if(empty($definedCurrency)) { $currency = CurrencySettings::get('currency'); return strtoupper($currency); } return strtoupper($definedCurrency); } /** * Get subscription payment info if available * * @return string */ public function getPaymentInfoAttribute(): string { return $this->getSubscriptionInfo(); } /** * Helper method to get subscription info * * @return string */ private function getSubscriptionInfo(): string { $subscriptionInfo = ''; $otherInfo = [ 'repeat_interval' => $this->billing_interval ?? '', 'times' => $this->bill_times ?? 0, 'recurring_total' => $this->recurring_total ?? 0, 'trial_days' => $this->trial_days ?? 0, ]; $recurringTotal = $this->recurring_total ?? 0; return Helper::generateSubscriptionInfo($otherInfo, $recurringTotal) ?? ''; } public function addLog($title, $description = '', $type = 'info', $by = '') { $logData = [ 'module_type' => 'FluentCart\App\Models\Subscription', 'module_id' => $this->id, 'module_name' => 'subscription', ]; if ($by) { $logData['created_by'] = $by; } fluent_cart_add_log($title, $description, $type, $logData); } public function getDownloads() { if (!$this->variation_id || $this->status !== Status::SUBSCRIPTION_ACTIVE) { return []; } $variationTitles = ProductVariation::pluck('variation_title', 'id'); $productTitles = Product::pluck('post_title', 'ID'); $downloads = ProductDownload::query()->where('post_id', $this->product_id)->get(); $downloads->filter(function ($download) { if (empty($download->product_variation_id)) { return true; } $ids = $download->product_variation_id; if (!is_array($ids)) { return true; } return empty($ids) || in_array($this->variation_id, $ids); }); return $downloads ->map(function ($download) use ($variationTitles, $productTitles) { $variationIds = $download->product_variation_id; $download->product_title = $productTitles[$download->post_id] ?? ''; $download->variation_ids = $variationIds; $download->variation_titles = array_map( fn($id) => $variationTitles[$id] ?? null, $variationIds ); unset($download->product_variation_id); return $download; }); } public function getMeta($metaKey, $default = null) { $exist = SubscriptionMeta::query() ->where('subscription_id', $this->id) ->where('meta_key', $metaKey) ->first(); if ($exist) { return $exist->meta_value; } return $default; } public function updateMeta($metaKey, $metaValue) { $exist = SubscriptionMeta::query() ->where('subscription_id', $this->id) ->where('meta_key', $metaKey) ->first(); if ($exist) { $exist->meta_value = $metaValue; $exist->save(); } else { SubscriptionMeta::query()->create([ 'subscription_id' => $this->id, //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_key' => $metaKey, //phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'meta_value' => $metaValue ]); } return true; } public function deleteMeta($metaKey) { return SubscriptionMeta::query() ->where('subscription_id', $this->id) ->where('meta_key', $metaKey) ->delete(); } public function getLatestTransaction() { return OrderTransaction::query() ->where('subscription_id', $this->id) ->orderBy('id', 'DESC') ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE) ->first(); } public function canUpgrade() { return Meta::query()->where('meta_key', 'variant_upgrade_path') ->where('object_id', $this->variation_id) ->exists() && in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING]); } public function canUpdatePaymentMethod() { $gateway = App::gateway($this->current_payment_method); if ($gateway && !in_array('card_update', $gateway->supportedFeatures)) { return false; } return in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING, Status::SUBSCRIPTION_PAUSED, Status::SUBSCRIPTION_INTENDED, Status::SUBSCRIPTION_PAST_DUE, Status::SUBSCRIPTION_FAILING, Status::SUBSCRIPTION_EXPIRING]); // past_due, is fallback for existing subscriptions, on new subscriptions update it will be expiring } public function canSwitchPaymentMethod() { $gateway = App::gateway($this->current_payment_method); if (!$gateway || empty(Arr::get($gateway->supportedFeatures, 'switch_payment_method'))) { return false; } return in_array($this->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING, Status::SUBSCRIPTION_PAUSED]); } public function switchablePaymentMethods() { $gateway = App::gateway($this->current_payment_method); if ($gateway && empty($gateway->supportedFeatures['switch_payment_method'])) { return []; } return Arr::get($gateway->supportedFeatures, 'switch_payment_method.supported_gateways', []); } public function canReactive() { if (!App::isProActive()) { return ''; } if (isset($this->config['upgraded_to_sub_id']) || $this->recurring_amount <= 0) { return ''; } if (isset($this->config['cancellation_reason']) && $this->config['cancellation_reason'] === 'refunded') { return ''; } $canReactivate = in_array($this->status, [Status::SUBSCRIPTION_CANCELED, Status::SUBSCRIPTION_FAILING, Status::SUBSCRIPTION_EXPIRED, Status::SUBSCRIPTION_PAUSED, Status::SUBSCRIPTION_EXPIRING, Status::SUBSCRIPTION_PAST_DUE]); return apply_filters('fluent_cart/subscription/can_reactivate', $canReactivate, [ 'subscription' => $this ]); } public function getReactivateUrl() { if (!$this->canReactive()) { return ''; } return add_query_arg([ 'fluent-cart' => 'reactivate-subscription', 'subscription_hash' => $this->uuid, ], home_url('/')); } public function getReactivateUrlAttribute() { return $this->getReactivateUrl(); } public function getViewUrl($type = 'customer') { if ($type == 'customer') { return TemplateService::getCustomerProfileUrl('subscription/' . $this->uuid); } return TemplateService::getAdminUrl('subscriptions/' . $this->id . '/view'); } public function hasAccessValidity() { $validAccessStatuses = [ Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING, Status::SUBSCRIPTION_COMPLETED ]; if (in_array($this->status, $validAccessStatuses)) { return true; } $invalidStatuses = [ Status::SUBSCRIPTION_EXPIRED, Status::SUBSCRIPTION_PAST_DUE, Status::SUBSCRIPTION_INTENDED, Status::SUBSCRIPTION_PENDING ]; if (in_array($this->status, $invalidStatuses)) { return false; } $nextBillingDate = $this->next_billing_date; if (!$nextBillingDate) { $nextBillingDate = $this->guessNextBillingDate(); } // now check the dates if (strtotime($nextBillingDate) > time()) { return true; } return false; } public function reSyncFromRemote() { if ($gateway = App::gateway($this->current_payment_method)) { if ($gateway->has('subscriptions')) { return $gateway->subscriptions->reSyncSubscriptionFromRemote($this); } } return new \WP_Error('invalid_payment_method', __('This payment method does not support remote resync', 'fluent-cart')); } public function cancelRemoteSubscription($args = []) { $args = wp_parse_args($args, [ 'reason' => '', 'fire_hooks' => true, 'note' => '', 'effective_from' => '' ]); if ($this->status === Status::SUBSCRIPTION_CANCELED) { return new \WP_Error('subscription_already_cancelled', __('This subscription is already cancelled.', 'fluent-cart')); } $gateway = App::gateway($this->current_payment_method); if ($gateway && $gateway->has('subscriptions')) { $cancelArgs = [ 'subscription_id' => $this->id, 'parent_order_id' => $this->parent_order_id, 'mode' => $this->order->mode, ]; $effectiveFrom = Arr::get($args, 'effective_from', ''); if ($effectiveFrom) { $cancelArgs['effective_from'] = $effectiveFrom; } $vendorCanceled = $gateway->subscriptions->cancel($this->vendor_subscription_id, $cancelArgs); if (is_wp_error($vendorCanceled)) { return $vendorCanceled; } $updateData = array_filter($vendorCanceled); } else { $vendorCanceled = new \WP_Error('invalid_payment_method', __('This payment method does not support remote subscription cancel', 'fluent-cart')); $updateData = [ 'canceled_at' => gmdate('Y-m-d H:i:s', time()) ]; } $updateData['status'] = Status::SUBSCRIPTION_CANCELED; if (empty($updateData['canceled_at']) && !$this->canceled_at) { $updateData['canceled_at'] = gmdate('Y-m-d H:i:s', time()); } if ($this->status === Status::SUBSCRIPTION_COMPLETED) { $updateData['status'] = Status::SUBSCRIPTION_COMPLETED; $updateData['canceled_at'] = NULL; } $config = $this->config; if ($args['reason']) { $config['cancellation_reason'] = $args['reason']; } $updateData['config'] = $config; if (Arr::get($args, 'effective_from') === 'immediately') { $updateData['next_billing_date'] = gmdate('Y-m-d H:i:s', time()); } $this->fill($updateData); $this->save(); $note = $args['note']; if (!$note) { $note = 'on customer request'; } if ($args['fire_hooks'] && $this->status !== Status::SUBSCRIPTION_COMPLETED) { (new SubscriptionCanceled($this, $this->order, $this->order->customer, $note))->dispatch(); } if ($args['note']) { $this->order->note = $note; $this->order->save(); } return [ 'subscription' => $this, 'vendor_result' => $vendorCanceled ]; } public function getCurrentRenewalAmount() { $currentRecurringAmount = (int)Arr::get($this->config, 'current_renewal_amount'); if ($currentRecurringAmount) { return $currentRecurringAmount; } return $this->recurring_total; } public function getRequiredBillTimes() { $billTimes = (int)$this->bill_times; if ($billTimes > 0) { $billTimes = $billTimes - $this->bill_count; if ($billTimes <= 0) { $transacactionsCount = OrderTransaction::query() ->where('subscription_id', $this->id) ->where('transaction_type', Status::TRANSACTION_TYPE_CHARGE) ->where('status', Status::TRANSACTION_SUCCEEDED) ->where('total', '>', 0) ->count(); $earlyPaymentHistory = $this->getMeta('early_payment_history', []); foreach ($earlyPaymentHistory as $earlyPayment) { $paidCount = (int) Arr::get($earlyPayment, 'count', 1); if ($paidCount > 1) { $transacactionsCount += ($paidCount - 1); } } if ($transacactionsCount != $this->bill_count) { $this->bill_count = $transacactionsCount; $this->save(); } $revisedBillTimes = $this->bill_times - $this->bill_count; if ($revisedBillTimes <= 0) { return -1; } return $revisedBillTimes; } } return $billTimes; } public function getReactivationTrialDays() { if (!$this->hasAccessValidity()) { return 0; } $nextBillingDate = $this->guessNextBillingDate(true); // @todo: Temporary fix for next billing date mismatch issue from migration // $nextBillingDate = $this->next_billing_date; // // if (!$nextBillingDate) { // $nextBillingDate = $this->guessNextBillingDate(true); // } $nextBillingDate = strtotime($nextBillingDate); $currentDate = time(); $trialDays = floor(($nextBillingDate - $currentDate) / DAY_IN_SECONDS); // Convert seconds to days if ($trialDays <= 1) { $trialDays = 0; // Ensure trial days are not negative } return $trialDays; } public function guessNextBillingDate($forced = false) { if ($this->next_billing_date && !$forced) { return $this->next_billing_date; } // preserve it during reactivation to maintain the billing cycle if ($this->next_billing_date && $this->status === Status::SUBSCRIPTION_CANCELED) { return $this->next_billing_date; } // we have to create a next billing date somehow!! $theLastOrder = Order::query() ->where(function ($q) { $q->where('parent_id', $this->parent_order_id) ->orWhere('id', $this->parent_order_id); }) ->orderBy('id', 'DESC') ->whereIn('payment_status', Status::getOrderPaymentSuccessStatuses()) ->first(); if ($theLastOrder) { $days = PaymentHelper::getIntervalDays($this->billing_interval); if ($theLastOrder->type == 'renewal') { $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + $days * DAY_IN_SECONDS); } else { if ($this->trial_days) { $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS); } else { $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($theLastOrder->created_at) + $days * DAY_IN_SECONDS); } } } else { $nextBillingDate = gmdate('Y-m-d H:i:s', strtotime($this->created_at) + (int)($this->trial_days) * DAY_IN_SECONDS); } return $nextBillingDate; } /** * Check and expire subscriptions past their grace period * * This method is called by the hourly scheduler to automatically expire * subscriptions that have missed payments and are past their grace period. * * Processes all candidates in batches to avoid memory issues. * The query example works as follows: * SELECT * FROM subscriptions WHERE status IN ('active', 'trialing', 'canceled') AND next_billing_date IS NOT NULL AND id > 0 -- last processed ID for batch cursor AND next_billing_date < DATE_SUB( '2026-02-17 10:00:00', INTERVAL ( CASE billing_interval WHEN 'daily' THEN 1 WHEN 'weekly' THEN 3 WHEN 'monthly' THEN 7 WHEN 'quarterly' THEN 15 WHEN 'half_yearly' THEN 15 WHEN 'yearly' THEN 15 ELSE 7 END ) DAY ) ORDER BY id ASC LIMIT 100; * * @param int $batchSize Number of subscriptions to process per batch * @return array Statistics about processed subscriptions */ public static function checkAndExpireSubscriptions($batchSize = 100) { $stats = [ 'checked' => 0, 'validity_expired' => 0, 'batches' => 0, ]; $lastId = 0; $gracePeriodDays = SubscriptionHelper::getSubscriptionsGracePeriodDays(); $caseSql = 'CASE billing_interval '; $bindings = []; foreach ($gracePeriodDays as $interval => $days) { $caseSql .= 'WHEN ? THEN ? '; $bindings[] = $interval; $bindings[] = $days; } $caseSql .= 'ELSE ? END'; $bindings[] = 7; $cutoffSql = "DATE_SUB(?, INTERVAL ($caseSql) DAY)"; do { // Include canceled subscriptions to check if validity is yet to expired $subscriptions = Subscription::query() ->whereIn('status', [ Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING, Status::SUBSCRIPTION_CANCELED, ]) ->whereNotNull('next_billing_date') ->where('id', '>', $lastId) ->whereRaw( "next_billing_date < $cutoffSql", array_merge( [gmdate('Y-m-d H:i:s', time())], $bindings ) ) ->orderBy('id', 'ASC') ->limit($batchSize) ->with(['order', 'customer']) ->get(); if ($subscriptions->isEmpty()) { break; // No more subscriptions to process } $stats['batches']++; $stats['checked'] += $subscriptions->count(); foreach ($subscriptions as $subscription) { if ($subscription->status === Status::SUBSCRIPTION_CANCELED) { if (isset($subscription->config['upgraded_to_sub_id'])) { continue; } } $gracePeriod = $gracePeriodDays[$subscription->billing_interval] ?? 7; $cutoff = gmdate('Y-m-d H:i:s', time() - ($gracePeriod * DAY_IN_SECONDS)); if ($subscription->next_billing_date < $cutoff) { $updateData = [ 'next_billing_date' => NULL, ]; // Only change status to EXPIRED for active/trialing subscriptions if ($subscription->status !== Status::SUBSCRIPTION_CANCELED) { $updateData['status'] = Status::SUBSCRIPTION_EXPIRED; } $subscription->updateMeta('validity_expired_at', gmdate('Y-m-d H:i:s')); $subscription->fill($updateData); $subscription->save(); $event = new \FluentCart\App\Events\Subscription\SubscriptionValidityExpired( $subscription, $subscription->order, $subscription->customer, ); $event->dispatch(); $stats['validity_expired']++; } } $lastId = $subscriptions->last()->id; unset($subscriptions); } while (true); if ($stats['checked'] > 0) { fluent_cart_add_log( 'Subscription Validity Expiration Check', sprintf( 'Checked: %d subscriptions, Status changed to Expired: %d, Batches: %d', $stats['checked'], $stats['validity_expired'], $stats['batches'] ), 'info', $stats ); } return $stats; } }