PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Modules / Subscriptions / Http / Controllers / SubscriptionController.php

SubscriptionController.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Modules/Subscriptions/Http/Controllers/SubscriptionController.php

596 lines 23.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\Subscriptions\Http\Controllers;
4
5 use FluentCart\App\App;
6 use FluentCart\App\Helpers\Status;
7 use FluentCart\App\Http\Controllers\Controller;
8 use FluentCart\App\Models\Order;
9 use FluentCart\App\Models\Subscription;
10 use FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway;
11 use FluentCart\App\Modules\Subscriptions\Services\EarlyPaymentFeature;
12 use FluentCart\App\Services\Reminders\ReminderService;
13 use FluentCart\App\Http\Requests\UpdateSubscriptionRequest;
14 use FluentCart\App\Http\Requests\UpdateVendorIdsRequest;
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;
19 use FluentCart\App\Modules\Subscriptions\Services\Filter\SubscriptionFilter;
20
21 class SubscriptionController extends Controller
22 {
23 public function index(Request $request): array
24 {
25
26
27 return [
28 'data' => SubscriptionFilter::fromRequest($request)->paginate(),
29 ];
30 }
31
32 public function getSubscriptionOrderDetails($subscriptionOrderId)
33 {
34
35 $subscription = Subscription::with([
36 'labels',
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',
42 'customer.shipping_address' => function ($query) {
43 $query->where('is_primary', 1);
44 },
45 'customer.billing_address' => function ($query) {
46 $query->where('is_primary', 1);
47 },
48 'order.billing_address',
49 'order.shipping_address',
50 ])
51 ->addAppends(['business_info', 'is_reverse_charge_tax_order', 'has_pending_skip', 'last_skipped_period'])
52 ->find($subscriptionOrderId);
53
54 if (is_wp_error($subscription) || empty($subscription)) {
55 return $this->entityNotFoundError(
56 __('Subscription not found', 'fluent-cart'),
57 __('Back to Subscription list', 'fluent-cart'),
58 '/subscriptions'
59 );
60 }
61
62 // Attach parent order's addresses and business info directly to subscription for consistent access
63 $subscription->billing_address = $subscription->order->billing_address ?? null;
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 }
72
73 $subscription->related_orders = Order::query()
74 ->with(['order_items' => function ($query) {
75 $query->select('id', 'order_id', 'post_title', 'title', 'quantity', 'payment_type', 'line_meta');
76 }])
77 ->where('id', $subscription->parent_order_id)
78 ->orWhere('parent_id', $subscription->parent_order_id)
79 ->orderBy('id', 'DESC')
80 ->get();
81
82
83 $subscription = apply_filters('fluent_cart/subscription/view', $subscription, []);
84
85 $reminderPermissions = (new ReminderService())->getSubscriptionReminderPermissions($subscription);
86
87 return $this->sendSuccess([
88 'subscription' => $subscription,
89 'selected_labels' => $subscription->labels->pluck('label_id'),
90 'reminder_permissions' => $reminderPermissions,
91 ]);
92
93 }
94
95 public function validateSubscription($subscription)
96 {
97 if (!$subscription) {
98 $this->sendError(['message' => __('Subscription not found!', 'fluent-cart')], 404);
99 }
100 }
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
112 public function cancelSubscription(Request $request, Order $order, Subscription $subscription)
113 {
114 $this->validateSubscription($subscription);
115 if ($error = $this->validateOrderBinding($order, $subscription)) return $error;
116
117 if (empty($request->getSafe('cancel_reason', 'sanitize_text_field'))) {
118 return $this->sendError([
119 'message' => __('Please select cancel reason!', 'fluent-cart')
120 ]);
121 }
122
123 $reason = $request->getSafe('cancel_reason', 'sanitize_text_field');
124
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)) {
133 return $this->sendError([
134 'message' => __('This subscription is already canceled, completed, or expired.', 'fluent-cart')
135 ]);
136 }
137
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);
140
141 if ($isAutomaticWithVendor) {
142 // Cancel at the payment gateway
143 $result = $subscription->cancelRemoteSubscription([
144 'reason' => $reason
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');
174 }
175
176 return $this->sendSuccess([
177 'message' => __('Subscription has been cancelled successfully!', 'fluent-cart'),
178 'subscription' => Subscription::query()->find($subscription->id)
179 ]);
180 }
181
182 public function reactivateSubscription(Request $request, Order $order, Subscription $subscription)
183 {
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
200 ]);
201 }
202
203 public function fetchSubscription(Request $request, Order $order, Subscription $subscription)
204 {
205 if ($error = $this->validateOrderBinding($order, $subscription)) return $error;
206 $result = $subscription->reSyncFromRemote();
207
208 if (is_wp_error($result)) {
209 return $this->sendError([
210 'message' => $result->get_error_message()
211 ]);
212 }
213
214 return $this->sendSuccess([
215 'message' => __('Subscription fetched successfully from remote payment gateway!', 'fluent-cart'),
216 'subscription' => $result
217 ]);
218 }
219
220 public function pauseSubscription(Request $request, Order $order, Subscription $subscription)
221 {
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)
238 ]);
239 }
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 ]);
260 }
261
262 public function updateSubscription(UpdateSubscriptionRequest $request, Order $order, Subscription $subscription)
263 {
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)
294 ]);
295 }
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
372 public function generateEarlyPaymentLink(Request $request, Order $order, Subscription $subscription)
373 {
374 $this->validateSubscription($subscription);
375
376 if (!EarlyPaymentFeature::isEnabled()) {
377 return $this->sendError([
378 'message' => __('Early payment is not enabled for this site.', 'fluent-cart')
379 ]);
380 }
381
382 $subscriptionOrderId = null;
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) {
388 $subscriptionOrderId = (int) $subscription->parent_order_id;
389 }
390
391 if ($subscriptionOrderId === null || $subscriptionOrderId !== (int) $order->id) {
392 return $this->sendError([
393 'message' => __('Invalid subscription for the specified order.', 'fluent-cart')
394 ]);
395 }
396
397 if ($subscription->bill_times <= 0) {
398 return $this->sendError([
399 'message' => __('Early payment is only available for installment subscriptions.', 'fluent-cart')
400 ]);
401 }
402
403 if (!in_array($subscription->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])) {
404 return $this->sendError([
405 'message' => __('Subscription must be active to make early payments.', 'fluent-cart')
406 ]);
407 }
408
409 $remaining = $subscription->bill_times - $subscription->bill_count;
410
411 if ($remaining <= 0) {
412 return $this->sendError([
413 'message' => __('All installments have already been paid.', 'fluent-cart')
414 ]);
415 }
416
417 $url = add_query_arg([
418 'fluent-cart' => 'early-installment-payment',
419 'subscription_hash' => $subscription->uuid,
420 ], home_url('/'));
421
422 return $this->sendSuccess([
423 'message' => __('Early payment link generated.', 'fluent-cart'),
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'],
593 ]);
594 }
595 }
596