← All changes
|
app/Modules/Subscriptions/Http/Controllers/SubscriptionController.php
+405
-18
1.5.1
→
1.6.5
View file →
| @@ -1,15 +1,22 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | |
| 3 | 3 | namespace FluentCart\App\Modules\Subscriptions\Http\Controllers; |
| 4 | 4 | |
| 5 | +use FluentCart\App\App; | |
| 5 | 6 | use FluentCart\App\Helpers\Status; |
| 6 | 7 | use FluentCart\App\Http\Controllers\Controller; |
| 7 | 8 | use FluentCart\App\Models\Order; |
| 8 | 9 | use FluentCart\App\Models\Subscription; |
| 10 | +use FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway; | |
| 9 | 11 | use FluentCart\App\Modules\Subscriptions\Services\EarlyPaymentFeature; |
| 10 | 12 | use FluentCart\App\Services\Reminders\ReminderService; |
| 13 | +use FluentCart\App\Http\Requests\UpdateSubscriptionRequest; | |
| 14 | +use FluentCart\App\Http\Requests\UpdateVendorIdsRequest; | |
| 11 | 15 | use FluentCart\Framework\Http\Request\Request; |
| 16 | +use FluentCart\Framework\Support\Arr; | |
| 17 | +use FluentCart\App\Modules\StoreManagedRenewal\Services\RenewalService; | |
| 18 | +use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService; | |
| 12 | 19 | use FluentCart\App\Modules\Subscriptions\Services\Filter\SubscriptionFilter; |
| 13 | 20 | |
| 14 | 21 | class SubscriptionController extends Controller |
| 15 | 22 | { |
| @@ -27,8 +34,12 @@ | ||
| 27 | 34 | |
| 28 | 35 | $subscription = Subscription::with([ |
| 29 | 36 | 'labels', |
| 30 | 37 | 'activities.user', |
| 38 | + // Served by the fct_order_transactions subscription_id index | |
| 39 | + // (OrderTransactionsMigrator base schema + migrated(), delivered to | |
| 40 | + // existing stores via DBMigrator::maybeMigrateDBChanges at 1.0.49). | |
| 41 | + 'transactions', | |
| 31 | 42 | 'customer.shipping_address' => function ($query) { |
| 32 | 43 | $query->where('is_primary', 1); |
| 33 | 44 | }, |
| 34 | 45 | 'customer.billing_address' => function ($query) { |
| @@ -36,9 +47,9 @@ | ||
| 36 | 47 | }, |
| 37 | 48 | 'order.billing_address', |
| 38 | 49 | 'order.shipping_address', |
| 39 | 50 | ]) |
| 40 | - ->addAppends(['business_info', 'is_reverse_charge_tax_order']) | |
| 51 | + ->addAppends(['business_info', 'is_reverse_charge_tax_order', 'has_pending_skip', 'last_skipped_period']) | |
| 41 | 52 | ->find($subscriptionOrderId); |
| 42 | 53 | |
| 43 | 54 | if (is_wp_error($subscription) || empty($subscription)) { |
| 44 | 55 | return $this->entityNotFoundError( |
| @@ -51,8 +62,14 @@ | ||
| 51 | 62 | // Attach parent order's addresses and business info directly to subscription for consistent access |
| 52 | 63 | $subscription->billing_address = $subscription->order->billing_address ?? null; |
| 53 | 64 | $subscription->shipping_address = $subscription->order->shipping_address ?? null; |
| 54 | 65 | $subscription->business_info = $subscription->order ? $subscription->order->getBusinessInfo() : []; |
| 66 | + // Upgrade eligibility, same flag the customer portal exposes | |
| 67 | + // (CustomerSubscriptionController) — gateway-free: an upgrade-path meta | |
| 68 | + // existence check plus a status check. | |
| 69 | + if ($subscription instanceof Subscription) { | |
| 70 | + $subscription->can_upgrade = $subscription->canUpgrade(); | |
| 71 | + } | |
| 55 | 72 | |
| 56 | 73 | $subscription->related_orders = Order::query() |
| 57 | 74 | ->with(['order_items' => function ($query) { |
| 58 | 75 | $query->select('id', 'order_id', 'post_title', 'title', 'quantity', 'payment_type', 'line_meta'); |
| @@ -81,11 +98,22 @@ | ||
| 81 | 98 | $this->sendError(['message' => __('Subscription not found!', 'fluent-cart')], 404); |
| 82 | 99 | } |
| 83 | 100 | } |
| 84 | 101 | |
| 102 | + private function validateOrderBinding(Order $order, Subscription $subscription) | |
| 103 | + { | |
| 104 | + $rootOrderId = $order->parent_id ? $order->parent_id : $order->id; | |
| 105 | + if ((int) $subscription->parent_order_id !== (int) $rootOrderId) { | |
| 106 | + return $this->sendError(['message' => __('Subscription does not belong to this order.', 'fluent-cart')], 403); | |
| 107 | + } | |
| 108 | + | |
| 109 | + return null; | |
| 110 | + } | |
| 111 | + | |
| 85 | 112 | public function cancelSubscription(Request $request, Order $order, Subscription $subscription) |
| 86 | 113 | { |
| 87 | 114 | $this->validateSubscription($subscription); |
| 115 | + if ($error = $this->validateOrderBinding($order, $subscription)) return $error; | |
| 88 | 116 | |
| 89 | 117 | if (empty($request->getSafe('cancel_reason', 'sanitize_text_field'))) { |
| 90 | 118 | return $this->sendError([ |
| 91 | 119 | 'message' => __('Please select cancel reason!', 'fluent-cart') |
| @@ -91,24 +119,59 @@ | ||
| 91 | 119 | 'message' => __('Please select cancel reason!', 'fluent-cart') |
| 92 | 120 | ]); |
| 93 | 121 | } |
| 94 | 122 | |
| 95 | - $result = $subscription->cancelRemoteSubscription([ | |
| 96 | - 'reason' => $request->getSafe('cancel_reason', 'sanitize_text_field') | |
| 97 | - ]); | |
| 123 | + $reason = $request->getSafe('cancel_reason', 'sanitize_text_field'); | |
| 98 | 124 | |
| 99 | - if (is_wp_error($result)) { | |
| 125 | + // Repeat cancels must not re-dispatch SubscriptionCanceled (duplicate | |
| 126 | + // cancellation emails/automations) — the UI hides the button, but the | |
| 127 | + // endpoint must guard too. | |
| 128 | + if (in_array($subscription->status, [ | |
| 129 | + Status::SUBSCRIPTION_CANCELED, | |
| 130 | + Status::SUBSCRIPTION_COMPLETED, | |
| 131 | + Status::SUBSCRIPTION_EXPIRED, | |
| 132 | + ], true)) { | |
| 100 | 133 | return $this->sendError([ |
| 101 | - 'message' => $result->get_error_message() | |
| 134 | + 'message' => __('This subscription is already canceled, completed, or expired.', 'fluent-cart') | |
| 102 | 135 | ]); |
| 103 | 136 | } |
| 104 | 137 | |
| 105 | - $vendorCancelled = $result['vendor_result']; | |
| 138 | + // Only cancel at gateway if it's an automatic subscription with a vendor subscription ID | |
| 139 | + $isAutomaticWithVendor = $subscription->collection_method === 'automatic' && !empty($subscription->vendor_subscription_id); | |
| 106 | 140 | |
| 107 | - if (is_wp_error($vendorCancelled)) { | |
| 108 | - return $this->sendError([ | |
| 109 | - 'message' => 'Subscription cancelled locally. Vendor Response: ' . $vendorCancelled->get_error_message() | |
| 141 | + if ($isAutomaticWithVendor) { | |
| 142 | + // Cancel at the payment gateway | |
| 143 | + $result = $subscription->cancelRemoteSubscription([ | |
| 144 | + 'reason' => $reason | |
| 110 | 145 | ]); |
| 146 | + | |
| 147 | + if (is_wp_error($result)) { | |
| 148 | + return $this->sendError([ | |
| 149 | + 'message' => $result->get_error_message() | |
| 150 | + ]); | |
| 151 | + } | |
| 152 | + | |
| 153 | + $vendorCancelled = $result['vendor_result']; | |
| 154 | + | |
| 155 | + if (is_wp_error($vendorCancelled)) { | |
| 156 | + return $this->sendError([ | |
| 157 | + /* translators: %1$s: error message returned by the payment gateway */ | |
| 158 | + 'message' => sprintf(__('Subscription cancelled locally. Vendor Response: %1$s', 'fluent-cart'), $vendorCancelled->get_error_message()) | |
| 159 | + ]); | |
| 160 | + } | |
| 161 | + } else { | |
| 162 | + // Manual subscription or automatic without vendor ID - just update locally | |
| 163 | + $subscription->fill([ | |
| 164 | + 'status' => Status::SUBSCRIPTION_CANCELED, | |
| 165 | + 'canceled_at' => gmdate('Y-m-d H:i:s'), | |
| 166 | + ])->save(); | |
| 167 | + | |
| 168 | + // Same record the vendor branch keeps via cancelRemoteSubscription — merged | |
| 169 | + // after save() so the write is not undone by the fill above. | |
| 170 | + $subscription->mergeConfig(['cancellation_reason' => $reason]); | |
| 171 | + | |
| 172 | + // Single cancel chokepoint — void renewals, clear reminders, email once. | |
| 173 | + SubscriptionService::finalizeCancellation($subscription, $reason ?: 'Subscription canceled by admin'); | |
| 111 | 174 | } |
| 112 | 175 | |
| 113 | 176 | return $this->sendSuccess([ |
| 114 | 177 | 'message' => __('Subscription has been cancelled successfully!', 'fluent-cart'), |
| @@ -117,15 +180,30 @@ | ||
| 117 | 180 | } |
| 118 | 181 | |
| 119 | 182 | public function reactivateSubscription(Request $request, Order $order, Subscription $subscription) |
| 120 | 183 | { |
| 121 | - return $this->sendError([ | |
| 122 | - 'message' => __('Not available yet', 'fluent-cart') | |
| 184 | + if ($error = $this->validateOrderBinding($order, $subscription)) return $error; | |
| 185 | + if (!$subscription->usesRenewalEngine()) { | |
| 186 | + return $this->sendError([ | |
| 187 | + 'message' => __('Only store-billed (manual or auto-charge) subscriptions can be reactivated.', 'fluent-cart') | |
| 188 | + ]); | |
| 189 | + } | |
| 190 | + | |
| 191 | + $result = SubscriptionService::reactivateSubscriptionLocally($subscription); | |
| 192 | + | |
| 193 | + if (is_wp_error($result)) { | |
| 194 | + return $this->sendError(['message' => $result->get_error_message()]); | |
| 195 | + } | |
| 196 | + | |
| 197 | + return $this->sendSuccess([ | |
| 198 | + 'message' => __('Subscription has been reactivated successfully!', 'fluent-cart'), | |
| 199 | + 'subscription' => $result | |
| 123 | 200 | ]); |
| 124 | 201 | } |
| 125 | 202 | |
| 126 | 203 | public function fetchSubscription(Request $request, Order $order, Subscription $subscription) |
| 127 | 204 | { |
| 205 | + if ($error = $this->validateOrderBinding($order, $subscription)) return $error; | |
| 128 | 206 | $result = $subscription->reSyncFromRemote(); |
| 129 | 207 | |
| 130 | 208 | if (is_wp_error($result)) { |
| 131 | 209 | return $this->sendError([ |
| @@ -140,21 +218,158 @@ | ||
| 140 | 218 | } |
| 141 | 219 | |
| 142 | 220 | public function pauseSubscription(Request $request, Order $order, Subscription $subscription) |
| 143 | 221 | { |
| 144 | - return $this->sendError([ | |
| 145 | - 'message' => __('Not available yet', 'fluent-cart') | |
| 222 | + $this->validateSubscription($subscription); | |
| 223 | + if ($error = $this->validateOrderBinding($order, $subscription)) return $error; | |
| 224 | + | |
| 225 | + $reason = $request->getSafe('reason', 'sanitize_text_field') ?: __('Paused by admin', 'fluent-cart'); | |
| 226 | + | |
| 227 | + $result = $subscription->pauseSubscription($reason); | |
| 228 | + | |
| 229 | + if (is_wp_error($result)) { | |
| 230 | + return $this->sendError([ | |
| 231 | + 'message' => $result->get_error_message() | |
| 232 | + ]); | |
| 233 | + } | |
| 234 | + | |
| 235 | + return $this->sendSuccess([ | |
| 236 | + 'message' => __('Subscription has been paused successfully!', 'fluent-cart'), | |
| 237 | + 'subscription' => Subscription::query()->find($subscription->id) | |
| 146 | 238 | ]); |
| 239 | + } | |
| 147 | 240 | |
| 241 | + public function resumeSubscription(Request $request, Order $order, Subscription $subscription) | |
| 242 | + { | |
| 243 | + $this->validateSubscription($subscription); | |
| 244 | + if ($error = $this->validateOrderBinding($order, $subscription)) return $error; | |
| 245 | + | |
| 246 | + $reason = $request->getSafe('reason', 'sanitize_text_field') ?: __('Resumed by admin', 'fluent-cart'); | |
| 247 | + | |
| 248 | + $result = $subscription->resumeSubscription($reason); | |
| 249 | + | |
| 250 | + if (is_wp_error($result)) { | |
| 251 | + return $this->sendError([ | |
| 252 | + 'message' => $result->get_error_message() | |
| 253 | + ]); | |
| 254 | + } | |
| 255 | + | |
| 256 | + return $this->sendSuccess([ | |
| 257 | + 'message' => __('Subscription has been resumed successfully!', 'fluent-cart'), | |
| 258 | + 'subscription' => Subscription::query()->find($subscription->id) | |
| 259 | + ]); | |
| 148 | 260 | } |
| 149 | 261 | |
| 150 | - public function resumeSubscription(Request $request, Order $order, Subscription $subscription) | |
| 262 | + public function updateSubscription(UpdateSubscriptionRequest $request, Order $order, Subscription $subscription) | |
| 151 | 263 | { |
| 152 | - return $this->sendError([ | |
| 153 | - 'message' => __('Not available yet', 'fluent-cart') | |
| 264 | + $this->validateSubscription($subscription); | |
| 265 | + if ($error = $this->validateOrderBinding($order, $subscription)) return $error; | |
| 266 | + | |
| 267 | + if (!$subscription->canUpdateDetails()) { | |
| 268 | + return $this->sendError([ | |
| 269 | + 'message' => __('Only manual subscriptions can be updated.', 'fluent-cart') | |
| 270 | + ]); | |
| 271 | + } | |
| 272 | + | |
| 273 | + $data = array_filter($request->all(), function ($value) { | |
| 274 | + return !is_null($value); | |
| 275 | + }); | |
| 276 | + | |
| 277 | + if (empty($data)) { | |
| 278 | + return $this->sendError([ | |
| 279 | + 'message' => __('No data provided for update.', 'fluent-cart') | |
| 280 | + ]); | |
| 281 | + } | |
| 282 | + | |
| 283 | + $result = $subscription->updateSubscription($data); | |
| 284 | + | |
| 285 | + if (is_wp_error($result)) { | |
| 286 | + return $this->sendError([ | |
| 287 | + 'message' => $result->get_error_message() | |
| 288 | + ]); | |
| 289 | + } | |
| 290 | + | |
| 291 | + return $this->sendSuccess([ | |
| 292 | + 'message' => __('Subscription has been updated successfully!', 'fluent-cart'), | |
| 293 | + 'subscription' => Subscription::query()->find($subscription->id) | |
| 154 | 294 | ]); |
| 155 | 295 | } |
| 156 | 296 | |
| 297 | + public function updateVendorIds(UpdateVendorIdsRequest $request, Order $order, Subscription $subscription) | |
| 298 | + { | |
| 299 | + $this->validateSubscription($subscription); | |
| 300 | + if ($error = $this->validateOrderBinding($order, $subscription)) return $error; | |
| 301 | + | |
| 302 | + $data = $request->all(); | |
| 303 | + $payload = []; | |
| 304 | + | |
| 305 | + // Only the keys actually sent are written — an absent key must not blank | |
| 306 | + // the stored id. | |
| 307 | + foreach (['vendor_subscription_id', 'vendor_customer_id'] as $field) { | |
| 308 | + if (array_key_exists($field, $data)) { | |
| 309 | + $payload[$field] = (string) $data[$field]; | |
| 310 | + } | |
| 311 | + } | |
| 312 | + | |
| 313 | + if (empty($payload)) { | |
| 314 | + return $this->sendError([ | |
| 315 | + 'message' => __('No data provided for update.', 'fluent-cart') | |
| 316 | + ]); | |
| 317 | + } | |
| 318 | + | |
| 319 | + $result = SubscriptionService::updateVendorIds($subscription, $payload); | |
| 320 | + | |
| 321 | + if (is_wp_error($result)) { | |
| 322 | + return $this->sendError([ | |
| 323 | + 'message' => $result->get_error_message() | |
| 324 | + ]); | |
| 325 | + } | |
| 326 | + | |
| 327 | + return $this->sendSuccess([ | |
| 328 | + 'message' => __('Vendor IDs have been updated successfully!', 'fluent-cart'), | |
| 329 | + 'subscription' => Subscription::query()->find($subscription->id) | |
| 330 | + ]); | |
| 331 | + } | |
| 332 | + | |
| 333 | + public function verifyVendorIds(UpdateVendorIdsRequest $request, Order $order, Subscription $subscription) | |
| 334 | + { | |
| 335 | + $this->validateSubscription($subscription); | |
| 336 | + if ($error = $this->validateOrderBinding($order, $subscription)) return $error; | |
| 337 | + | |
| 338 | + if (!$subscription->canEditVendorIds()) { | |
| 339 | + return $this->sendError([ | |
| 340 | + 'message' => __('Vendor IDs can only be edited on an active gateway-billed subscription.', 'fluent-cart') | |
| 341 | + ]); | |
| 342 | + } | |
| 343 | + | |
| 344 | + if (!$subscription->canVerifyVendorIds()) { | |
| 345 | + return $this->sendError([ | |
| 346 | + 'message' => __('This payment method does not support subscription lookup.', 'fluent-cart') | |
| 347 | + ]); | |
| 348 | + } | |
| 349 | + | |
| 350 | + /** @var AbstractPaymentGateway $gateway */ | |
| 351 | + $gateway = App::gateway($subscription->current_payment_method); | |
| 352 | + | |
| 353 | + $data = $request->all(); | |
| 354 | + | |
| 355 | + $result = $gateway->subscriptions->verifyVendorSubscription([ | |
| 356 | + 'vendor_subscription_id' => (string) Arr::get($data, 'vendor_subscription_id', ''), | |
| 357 | + 'vendor_customer_id' => (string) Arr::get($data, 'vendor_customer_id', ''), | |
| 358 | + ], $order->mode); | |
| 359 | + | |
| 360 | + if (is_wp_error($result)) { | |
| 361 | + return $this->sendError([ | |
| 362 | + 'message' => $result->get_error_message() | |
| 363 | + ]); | |
| 364 | + } | |
| 365 | + | |
| 366 | + return $this->sendSuccess([ | |
| 367 | + 'message' => __('Subscription found at the payment gateway.', 'fluent-cart'), | |
| 368 | + 'verification' => $result | |
| 369 | + ]); | |
| 370 | + } | |
| 371 | + | |
| 157 | 372 | public function generateEarlyPaymentLink(Request $request, Order $order, Subscription $subscription) |
| 158 | 373 | { |
| 159 | 374 | $this->validateSubscription($subscription); |
| 160 | 375 | |
| @@ -164,9 +379,13 @@ | ||
| 164 | 379 | ]); |
| 165 | 380 | } |
| 166 | 381 | |
| 167 | 382 | $subscriptionOrderId = null; |
| 168 | - if (property_exists($subscription, 'parent_order_id') && $subscription->parent_order_id) { | |
| 383 | + // property_exists() cannot see Eloquent attributes (they live in the | |
| 384 | + // model's attributes array behind __get), so it returned false even for | |
| 385 | + // populated rows and every request died on the mismatch guard below. | |
| 386 | + // isset() routes through __isset and sees the real attribute. | |
| 387 | + if (isset($subscription->parent_order_id) && $subscription->parent_order_id) { | |
| 169 | 388 | $subscriptionOrderId = (int) $subscription->parent_order_id; |
| 170 | 389 | } |
| 171 | 390 | |
| 172 | 391 | if ($subscriptionOrderId === null || $subscriptionOrderId !== (int) $order->id) { |
| @@ -202,7 +421,175 @@ | ||
| 202 | 421 | |
| 203 | 422 | return $this->sendSuccess([ |
| 204 | 423 | 'message' => __('Early payment link generated.', 'fluent-cart'), |
| 205 | 424 | 'payment_url' => $url, |
| 425 | + ]); | |
| 426 | + } | |
| 427 | + | |
| 428 | + /** | |
| 429 | + * Run one immediate off-session charge attempt on the subscription's open | |
| 430 | + * renewal invoice. A card decline is HTTP 200 with status "failed" — the | |
| 431 | + * request worked, the charge did not. State violations are sendError 4xx. | |
| 432 | + */ | |
| 433 | + public function chargeNow(Request $request, Order $order, Subscription $subscription) | |
| 434 | + { | |
| 435 | + $this->validateSubscription($subscription); | |
| 436 | + if ($error = $this->validateOrderBinding($order, $subscription)) return $error; | |
| 437 | + | |
| 438 | + $invoice = Order::query() | |
| 439 | + ->where('parent_id', $subscription->parent_order_id) | |
| 440 | + ->where('type', Status::ORDER_TYPE_RENEWAL) | |
| 441 | + ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED]) | |
| 442 | + ->orderBy('id', 'DESC') | |
| 443 | + ->first(); | |
| 444 | + | |
| 445 | + if (!$invoice) { | |
| 446 | + return $this->sendError([ | |
| 447 | + 'message' => __('There is no open renewal order to charge for this subscription.', 'fluent-cart') | |
| 448 | + ]); | |
| 449 | + } | |
| 450 | + | |
| 451 | + $result = \FluentCart\App\Modules\Subscriptions\Services\SystemChargeService::chargeNow( | |
| 452 | + $invoice, | |
| 453 | + $subscription, | |
| 454 | + get_current_user_id() | |
| 455 | + ); | |
| 456 | + | |
| 457 | + if (is_wp_error($result)) { | |
| 458 | + return $this->sendError([ | |
| 459 | + 'message' => $result->get_error_message() | |
| 460 | + ]); | |
| 461 | + } | |
| 462 | + | |
| 463 | + return $this->sendSuccess([ | |
| 464 | + 'status' => $result['status'], | |
| 465 | + 'message' => $result['message'], | |
| 466 | + 'subscription' => Subscription::query()->find($subscription->id) | |
| 467 | + ]); | |
| 468 | + } | |
| 469 | + | |
| 470 | + public function createRenewalNow(Request $request, Order $order, Subscription $subscription) | |
| 471 | + { | |
| 472 | + $this->validateSubscription($subscription); | |
| 473 | + if ($error = $this->validateOrderBinding($order, $subscription)) return $error; | |
| 474 | + | |
| 475 | + if (!$subscription->usesRenewalEngine()) { | |
| 476 | + return $this->sendError([ | |
| 477 | + 'message' => __('Creating a renewal is only available for store-billed (manual or auto-charge) subscriptions.', 'fluent-cart') | |
| 478 | + ]); | |
| 479 | + } | |
| 480 | + | |
| 481 | + if (!in_array($subscription->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])) { | |
| 482 | + return $this->sendError([ | |
| 483 | + 'message' => __('A renewal can only be created for active or trialing subscriptions.', 'fluent-cart') | |
| 484 | + ]); | |
| 485 | + } | |
| 486 | + | |
| 487 | + if (!$subscription->next_billing_date) { | |
| 488 | + return $this->sendError([ | |
| 489 | + 'message' => __('Subscription has no upcoming billing date.', 'fluent-cart') | |
| 490 | + ]); | |
| 491 | + } | |
| 492 | + | |
| 493 | + $result = RenewalService::createRenewalOrders($subscription); | |
| 494 | + | |
| 495 | + if (is_wp_error($result)) { | |
| 496 | + return $this->sendError([ | |
| 497 | + 'message' => $result->get_error_message() | |
| 498 | + ]); | |
| 499 | + } | |
| 500 | + | |
| 501 | + $renewal = !empty($result) ? $result[0] : null; | |
| 502 | + | |
| 503 | + // Renewal already exists: manual has nothing to charge; system charges | |
| 504 | + // the existing open renewal (one subscription per parent order). | |
| 505 | + if (!$renewal) { | |
| 506 | + if (!$subscription->isSystem()) { | |
| 507 | + return $this->sendError([ | |
| 508 | + 'message' => __('A pending renewal already exists for this subscription.', 'fluent-cart') | |
| 509 | + ], 422); | |
| 510 | + } | |
| 511 | + | |
| 512 | + $renewal = Order::query() | |
| 513 | + ->where('parent_id', $subscription->parent_order_id) | |
| 514 | + ->where('type', Status::ORDER_TYPE_RENEWAL) | |
| 515 | + ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED]) | |
| 516 | + ->orderBy('id', 'DESC') | |
| 517 | + ->first(); | |
| 518 | + | |
| 519 | + if (!$renewal) { | |
| 520 | + return $this->sendError([ | |
| 521 | + 'message' => __('A renewal already exists but is not open for charging.', 'fluent-cart') | |
| 522 | + ], 422); | |
| 523 | + } | |
| 524 | + } | |
| 525 | + | |
| 526 | + // System charges immediately; a decline falls back to the retry/dunning | |
| 527 | + // flow. Manual just gets the pay-now renewal + email. | |
| 528 | + if ($subscription->isSystem()) { | |
| 529 | + $chargeResult = \FluentCart\App\Modules\Subscriptions\Services\SystemChargeService::chargeNow( | |
| 530 | + $renewal, | |
| 531 | + $subscription, | |
| 532 | + get_current_user_id() | |
| 533 | + ); | |
| 534 | + | |
| 535 | + if (is_wp_error($chargeResult)) { | |
| 536 | + return $this->sendError([ | |
| 537 | + 'message' => $chargeResult->get_error_message() | |
| 538 | + ]); | |
| 539 | + } | |
| 540 | + | |
| 541 | + return $this->sendSuccess([ | |
| 542 | + 'status' => $chargeResult['status'], | |
| 543 | + 'message' => $chargeResult['message'], | |
| 544 | + 'subscription' => Subscription::query()->find($subscription->id) | |
| 545 | + ]); | |
| 546 | + } | |
| 547 | + | |
| 548 | + return $this->sendSuccess([ | |
| 549 | + 'message' => __('Renewal has been created successfully.', 'fluent-cart'), | |
| 550 | + 'renewal' => $renewal | |
| 551 | + ]); | |
| 552 | + } | |
| 553 | + | |
| 554 | + public function skipRenewal(Request $request, Order $order, Subscription $subscription) | |
| 555 | + { | |
| 556 | + $this->validateSubscription($subscription); | |
| 557 | + if ($error = $this->validateOrderBinding($order, $subscription)) return $error; | |
| 558 | + | |
| 559 | + if (!$subscription->usesRenewalEngine()) { | |
| 560 | + return $this->sendError([ | |
| 561 | + 'message' => __('Skip next period is only available for store-billed (manual or auto-charge) subscriptions.', 'fluent-cart') | |
| 562 | + ]); | |
| 563 | + } | |
| 564 | + | |
| 565 | + if (!in_array($subscription->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])) { | |
| 566 | + return $this->sendError([ | |
| 567 | + 'message' => __('Period can only be skipped for active or trialing subscriptions.', 'fluent-cart') | |
| 568 | + ]); | |
| 569 | + } | |
| 570 | + | |
| 571 | + if (!$subscription->next_billing_date) { | |
| 572 | + return $this->sendError([ | |
| 573 | + 'message' => __('Subscription has no upcoming billing date to skip.', 'fluent-cart') | |
| 574 | + ]); | |
| 575 | + } | |
| 576 | + | |
| 577 | + $note = (string) $request->getSafe('reason', 'sanitize_text_field', ''); | |
| 578 | + | |
| 579 | + $result = RenewalService::skipNextPeriod($subscription, $note); | |
| 580 | + | |
| 581 | + if (empty($result['skipped'])) { | |
| 582 | + // Already pending, lost a race, or nothing to advance — the no-stacking | |
| 583 | + // invariant is enforced inside skipNextPeriod(), so report it plainly. | |
| 584 | + return $this->sendError([ | |
| 585 | + 'message' => __('The next period could not be skipped. It may have already been skipped.', 'fluent-cart') | |
| 586 | + ]); | |
| 587 | + } | |
| 588 | + | |
| 589 | + return $this->sendSuccess([ | |
| 590 | + 'message' => __('Billing period has been skipped successfully.', 'fluent-cart'), | |
| 591 | + 'old_next_billing_date' => $result['old_next_billing_date'], | |
| 592 | + 'new_next_billing_date' => $result['new_next_billing_date'], | |
| 206 | 593 | ]); |
| 207 | 594 | } |
| 208 | 595 | } |