PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
← All changes | app/Modules/Subscriptions/Http/Controllers/SubscriptionController.php +405 -18 1.5.0 → 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 }