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 +407 -18 1.3.20 → 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,8 +47,9 @@
36 47 },
37 48 'order.billing_address',
38 49 'order.shipping_address',
39 50 ])
51 + ->addAppends(['business_info', 'is_reverse_charge_tax_order', 'has_pending_skip', 'last_skipped_period'])
40 52 ->find($subscriptionOrderId);
41 53
42 54 if (is_wp_error($subscription) || empty($subscription)) {
43 55 return $this->entityNotFoundError(
@@ -46,11 +58,18 @@
46 58 '/subscriptions'
47 59 );
48 60 }
49 61
50 - // Attach parent order's addresses directly to subscription for consistent access
62 + // Attach parent order's addresses and business info directly to subscription for consistent access
51 63 $subscription->billing_address = $subscription->order->billing_address ?? null;
52 64 $subscription->shipping_address = $subscription->order->shipping_address ?? null;
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 + }
53 72
54 73 $subscription->related_orders = Order::query()
55 74 ->with(['order_items' => function ($query) {
56 75 $query->select('id', 'order_id', 'post_title', 'title', 'quantity', 'payment_type', 'line_meta');
@@ -79,11 +98,22 @@
79 98 $this->sendError(['message' => __('Subscription not found!', 'fluent-cart')], 404);
80 99 }
81 100 }
82 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 +
83 112 public function cancelSubscription(Request $request, Order $order, Subscription $subscription)
84 113 {
85 114 $this->validateSubscription($subscription);
115 + if ($error = $this->validateOrderBinding($order, $subscription)) return $error;
86 116
87 117 if (empty($request->getSafe('cancel_reason', 'sanitize_text_field'))) {
88 118 return $this->sendError([
89 119 'message' => __('Please select cancel reason!', 'fluent-cart')
@@ -89,24 +119,59 @@
89 119 'message' => __('Please select cancel reason!', 'fluent-cart')
90 120 ]);
91 121 }
92 122
93 - $result = $subscription->cancelRemoteSubscription([
94 - 'reason' => $request->getSafe('cancel_reason', 'sanitize_text_field')
95 - ]);
123 + $reason = $request->getSafe('cancel_reason', 'sanitize_text_field');
96 124
97 - 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)) {
98 133 return $this->sendError([
99 - 'message' => $result->get_error_message()
134 + 'message' => __('This subscription is already canceled, completed, or expired.', 'fluent-cart')
100 135 ]);
101 136 }
102 137
103 - $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);
104 140
105 - if (is_wp_error($vendorCancelled)) {
106 - return $this->sendError([
107 - '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
108 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');
109 174 }
110 175
111 176 return $this->sendSuccess([
112 177 'message' => __('Subscription has been cancelled successfully!', 'fluent-cart'),
@@ -115,15 +180,30 @@
115 180 }
116 181
117 182 public function reactivateSubscription(Request $request, Order $order, Subscription $subscription)
118 183 {
119 - return $this->sendError([
120 - '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
121 200 ]);
122 201 }
123 202
124 203 public function fetchSubscription(Request $request, Order $order, Subscription $subscription)
125 204 {
205 + if ($error = $this->validateOrderBinding($order, $subscription)) return $error;
126 206 $result = $subscription->reSyncFromRemote();
127 207
128 208 if (is_wp_error($result)) {
129 209 return $this->sendError([
@@ -138,21 +218,158 @@
138 218 }
139 219
140 220 public function pauseSubscription(Request $request, Order $order, Subscription $subscription)
141 221 {
142 - return $this->sendError([
143 - '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)
144 238 ]);
239 + }
145 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 + ]);
146 260 }
147 261
148 - public function resumeSubscription(Request $request, Order $order, Subscription $subscription)
262 + public function updateSubscription(UpdateSubscriptionRequest $request, Order $order, Subscription $subscription)
149 263 {
150 - return $this->sendError([
151 - '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)
152 294 ]);
153 295 }
154 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 +
155 372 public function generateEarlyPaymentLink(Request $request, Order $order, Subscription $subscription)
156 373 {
157 374 $this->validateSubscription($subscription);
158 375
@@ -162,9 +379,13 @@
162 379 ]);
163 380 }
164 381
165 382 $subscriptionOrderId = null;
166 - 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) {
167 388 $subscriptionOrderId = (int) $subscription->parent_order_id;
168 389 }
169 390
170 391 if ($subscriptionOrderId === null || $subscriptionOrderId !== (int) $order->id) {
@@ -200,7 +421,175 @@
200 421
201 422 return $this->sendSuccess([
202 423 'message' => __('Early payment link generated.', 'fluent-cart'),
203 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'],
204 593 ]);
205 594 }
206 595 }