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 -22 1.5.4 → 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,28 +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' => sprintf(
110 - /* translators: %1$s: error message returned by the payment vendor */
111 - __('Subscription cancelled locally. Vendor Response: %1$s', 'fluent-cart'),
112 - $vendorCancelled->get_error_message()
113 - )
141 + if ($isAutomaticWithVendor) {
142 + // Cancel at the payment gateway
143 + $result = $subscription->cancelRemoteSubscription([
144 + 'reason' => $reason
114 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');
115 174 }
116 175
117 176 return $this->sendSuccess([
118 177 'message' => __('Subscription has been cancelled successfully!', 'fluent-cart'),
@@ -121,15 +180,30 @@
121 180 }
122 181
123 182 public function reactivateSubscription(Request $request, Order $order, Subscription $subscription)
124 183 {
125 - return $this->sendError([
126 - '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
127 200 ]);
128 201 }
129 202
130 203 public function fetchSubscription(Request $request, Order $order, Subscription $subscription)
131 204 {
205 + if ($error = $this->validateOrderBinding($order, $subscription)) return $error;
132 206 $result = $subscription->reSyncFromRemote();
133 207
134 208 if (is_wp_error($result)) {
135 209 return $this->sendError([
@@ -144,21 +218,158 @@
144 218 }
145 219
146 220 public function pauseSubscription(Request $request, Order $order, Subscription $subscription)
147 221 {
148 - return $this->sendError([
149 - '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)
150 238 ]);
239 + }
151 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 + ]);
152 260 }
153 261
154 - public function resumeSubscription(Request $request, Order $order, Subscription $subscription)
262 + public function updateSubscription(UpdateSubscriptionRequest $request, Order $order, Subscription $subscription)
155 263 {
156 - return $this->sendError([
157 - '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)
158 294 ]);
159 295 }
160 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 +
161 372 public function generateEarlyPaymentLink(Request $request, Order $order, Subscription $subscription)
162 373 {
163 374 $this->validateSubscription($subscription);
164 375
@@ -168,9 +379,13 @@
168 379 ]);
169 380 }
170 381
171 382 $subscriptionOrderId = null;
172 - 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) {
173 388 $subscriptionOrderId = (int) $subscription->parent_order_id;
174 389 }
175 390
176 391 if ($subscriptionOrderId === null || $subscriptionOrderId !== (int) $order->id) {
@@ -206,7 +421,175 @@
206 421
207 422 return $this->sendSuccess([
208 423 'message' => __('Early payment link generated.', 'fluent-cart'),
209 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'],
210 593 ]);
211 594 }
212 595 }