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

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

497 lines 19.1 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\Helpers\Status;
6 use FluentCart\App\Http\Controllers\Controller;
7 use FluentCart\App\Models\Order;
8 use FluentCart\App\Models\Subscription;
9 use FluentCart\App\Modules\Subscriptions\Services\EarlyPaymentFeature;
10 use FluentCart\App\Services\Reminders\ReminderService;
11 use FluentCart\App\Http\Requests\UpdateSubscriptionRequest;
12 use FluentCart\Framework\Http\Request\Request;
13 use FluentCart\App\Modules\StoreManagedRenewal\Services\RenewalService;
14 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
15 use FluentCart\App\Modules\Subscriptions\Services\Filter\SubscriptionFilter;
16
17 class SubscriptionController extends Controller
18 {
19 public function index(Request $request): array
20 {
21
22
23 return [
24 'data' => SubscriptionFilter::fromRequest($request)->paginate(),
25 ];
26 }
27
28 public function getSubscriptionOrderDetails($subscriptionOrderId)
29 {
30
31 $subscription = Subscription::with([
32 'labels',
33 'activities.user',
34 'customer.shipping_address' => function ($query) {
35 $query->where('is_primary', 1);
36 },
37 'customer.billing_address' => function ($query) {
38 $query->where('is_primary', 1);
39 },
40 'order.billing_address',
41 'order.shipping_address',
42 ])
43 ->addAppends(['business_info', 'is_reverse_charge_tax_order', 'has_pending_skip', 'last_skipped_period'])
44 ->find($subscriptionOrderId);
45
46 if (is_wp_error($subscription) || empty($subscription)) {
47 return $this->entityNotFoundError(
48 __('Subscription not found', 'fluent-cart'),
49 __('Back to Subscription list', 'fluent-cart'),
50 '/subscriptions'
51 );
52 }
53
54 // Attach parent order's addresses and business info directly to subscription for consistent access
55 $subscription->billing_address = $subscription->order->billing_address ?? null;
56 $subscription->shipping_address = $subscription->order->shipping_address ?? null;
57 $subscription->business_info = $subscription->order ? $subscription->order->getBusinessInfo() : [];
58
59 $subscription->related_orders = Order::query()
60 ->with(['order_items' => function ($query) {
61 $query->select('id', 'order_id', 'post_title', 'title', 'quantity', 'payment_type', 'line_meta');
62 }])
63 ->where('id', $subscription->parent_order_id)
64 ->orWhere('parent_id', $subscription->parent_order_id)
65 ->orderBy('id', 'DESC')
66 ->get();
67
68
69 $subscription = apply_filters('fluent_cart/subscription/view', $subscription, []);
70
71 $reminderPermissions = (new ReminderService())->getSubscriptionReminderPermissions($subscription);
72
73 return $this->sendSuccess([
74 'subscription' => $subscription,
75 'selected_labels' => $subscription->labels->pluck('label_id'),
76 'reminder_permissions' => $reminderPermissions,
77 ]);
78
79 }
80
81 public function validateSubscription($subscription)
82 {
83 if (!$subscription) {
84 $this->sendError(['message' => __('Subscription not found!', 'fluent-cart')], 404);
85 }
86 }
87
88 private function validateOrderBinding(Order $order, Subscription $subscription)
89 {
90 if ((int) $subscription->parent_order_id !== (int) $order->id) {
91 return $this->sendError(['message' => __('Subscription does not belong to this order.', 'fluent-cart')], 403);
92 }
93 return null;
94 }
95
96 public function cancelSubscription(Request $request, Order $order, Subscription $subscription)
97 {
98 $this->validateSubscription($subscription);
99 if ($error = $this->validateOrderBinding($order, $subscription)) return $error;
100
101 if (empty($request->getSafe('cancel_reason', 'sanitize_text_field'))) {
102 return $this->sendError([
103 'message' => __('Please select cancel reason!', 'fluent-cart')
104 ]);
105 }
106
107 $reason = $request->getSafe('cancel_reason', 'sanitize_text_field');
108
109 // Repeat cancels must not re-dispatch SubscriptionCanceled (duplicate
110 // cancellation emails/automations) — the UI hides the button, but the
111 // endpoint must guard too.
112 if (in_array($subscription->status, [
113 Status::SUBSCRIPTION_CANCELED,
114 Status::SUBSCRIPTION_COMPLETED,
115 Status::SUBSCRIPTION_EXPIRED,
116 ], true)) {
117 return $this->sendError([
118 'message' => __('This subscription is already canceled, completed, or expired.', 'fluent-cart')
119 ]);
120 }
121
122 // Only cancel at gateway if it's an automatic subscription with a vendor subscription ID
123 $isAutomaticWithVendor = $subscription->collection_method === 'automatic' && !empty($subscription->vendor_subscription_id);
124
125 if ($isAutomaticWithVendor) {
126 // Cancel at the payment gateway
127 $result = $subscription->cancelRemoteSubscription([
128 'reason' => $reason
129 ]);
130
131 if (is_wp_error($result)) {
132 return $this->sendError([
133 'message' => $result->get_error_message()
134 ]);
135 }
136
137 $vendorCancelled = $result['vendor_result'];
138
139 if (is_wp_error($vendorCancelled)) {
140 return $this->sendError([
141 /* translators: %1$s: error message returned by the payment gateway */
142 'message' => sprintf(__('Subscription cancelled locally. Vendor Response: %1$s', 'fluent-cart'), $vendorCancelled->get_error_message())
143 ]);
144 }
145 } else {
146 // Manual subscription or automatic without vendor ID - just update locally
147 $subscription->fill([
148 'status' => Status::SUBSCRIPTION_CANCELED,
149 'canceled_at' => gmdate('Y-m-d H:i:s'),
150 ])->save();
151
152 // Single cancel chokepoint — void renewals, clear reminders, email once.
153 SubscriptionService::finalizeCancellation($subscription, $reason ?: 'Subscription canceled by admin');
154 }
155
156 return $this->sendSuccess([
157 'message' => __('Subscription has been cancelled successfully!', 'fluent-cart'),
158 'subscription' => Subscription::query()->find($subscription->id)
159 ]);
160 }
161
162 public function reactivateSubscription(Request $request, Order $order, Subscription $subscription)
163 {
164 if ($error = $this->validateOrderBinding($order, $subscription)) return $error;
165 if (!$subscription->usesRenewalEngine()) {
166 return $this->sendError([
167 'message' => __('Only store-billed (manual or auto-charge) subscriptions can be reactivated.', 'fluent-cart')
168 ]);
169 }
170
171 $result = SubscriptionService::reactivateSubscriptionLocally($subscription);
172
173 if (is_wp_error($result)) {
174 return $this->sendError(['message' => $result->get_error_message()]);
175 }
176
177 return $this->sendSuccess([
178 'message' => __('Subscription has been reactivated successfully!', 'fluent-cart'),
179 'subscription' => $result
180 ]);
181 }
182
183 public function fetchSubscription(Request $request, Order $order, Subscription $subscription)
184 {
185 if ($error = $this->validateOrderBinding($order, $subscription)) return $error;
186 $result = $subscription->reSyncFromRemote();
187
188 if (is_wp_error($result)) {
189 return $this->sendError([
190 'message' => $result->get_error_message()
191 ]);
192 }
193
194 return $this->sendSuccess([
195 'message' => __('Subscription fetched successfully from remote payment gateway!', 'fluent-cart'),
196 'subscription' => $result
197 ]);
198 }
199
200 public function pauseSubscription(Request $request, Order $order, Subscription $subscription)
201 {
202 $this->validateSubscription($subscription);
203 if ($error = $this->validateOrderBinding($order, $subscription)) return $error;
204
205 $reason = $request->getSafe('reason', 'sanitize_text_field') ?: __('Paused by admin', 'fluent-cart');
206
207 $result = $subscription->pauseSubscription($reason);
208
209 if (is_wp_error($result)) {
210 return $this->sendError([
211 'message' => $result->get_error_message()
212 ]);
213 }
214
215 return $this->sendSuccess([
216 'message' => __('Subscription has been paused successfully!', 'fluent-cart'),
217 'subscription' => Subscription::query()->find($subscription->id)
218 ]);
219 }
220
221 public function resumeSubscription(Request $request, Order $order, Subscription $subscription)
222 {
223 $this->validateSubscription($subscription);
224 if ($error = $this->validateOrderBinding($order, $subscription)) return $error;
225
226 $reason = $request->getSafe('reason', 'sanitize_text_field') ?: __('Resumed by admin', 'fluent-cart');
227
228 $result = $subscription->resumeSubscription($reason);
229
230 if (is_wp_error($result)) {
231 return $this->sendError([
232 'message' => $result->get_error_message()
233 ]);
234 }
235
236 return $this->sendSuccess([
237 'message' => __('Subscription has been resumed successfully!', 'fluent-cart'),
238 'subscription' => Subscription::query()->find($subscription->id)
239 ]);
240 }
241
242 public function updateSubscription(UpdateSubscriptionRequest $request, Order $order, Subscription $subscription)
243 {
244 $this->validateSubscription($subscription);
245 if ($error = $this->validateOrderBinding($order, $subscription)) return $error;
246
247 if (!$subscription->canUpdateDetails()) {
248 return $this->sendError([
249 'message' => __('Only manual subscriptions can be updated.', 'fluent-cart')
250 ]);
251 }
252
253 $data = array_filter($request->all(), function ($value) {
254 return !is_null($value);
255 });
256
257 if (empty($data)) {
258 return $this->sendError([
259 'message' => __('No data provided for update.', 'fluent-cart')
260 ]);
261 }
262
263 $result = $subscription->updateSubscription($data);
264
265 if (is_wp_error($result)) {
266 return $this->sendError([
267 'message' => $result->get_error_message()
268 ]);
269 }
270
271 return $this->sendSuccess([
272 'message' => __('Subscription has been updated successfully!', 'fluent-cart'),
273 'subscription' => Subscription::query()->find($subscription->id)
274 ]);
275 }
276
277 public function generateEarlyPaymentLink(Request $request, Order $order, Subscription $subscription)
278 {
279 $this->validateSubscription($subscription);
280
281 if (!EarlyPaymentFeature::isEnabled()) {
282 return $this->sendError([
283 'message' => __('Early payment is not enabled for this site.', 'fluent-cart')
284 ]);
285 }
286
287 $subscriptionOrderId = null;
288 if (property_exists($subscription, 'parent_order_id') && $subscription->parent_order_id) {
289 $subscriptionOrderId = (int) $subscription->parent_order_id;
290 }
291
292 if ($subscriptionOrderId === null || $subscriptionOrderId !== (int) $order->id) {
293 return $this->sendError([
294 'message' => __('Invalid subscription for the specified order.', 'fluent-cart')
295 ]);
296 }
297
298 if ($subscription->bill_times <= 0) {
299 return $this->sendError([
300 'message' => __('Early payment is only available for installment subscriptions.', 'fluent-cart')
301 ]);
302 }
303
304 if (!in_array($subscription->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])) {
305 return $this->sendError([
306 'message' => __('Subscription must be active to make early payments.', 'fluent-cart')
307 ]);
308 }
309
310 $remaining = $subscription->bill_times - $subscription->bill_count;
311
312 if ($remaining <= 0) {
313 return $this->sendError([
314 'message' => __('All installments have already been paid.', 'fluent-cart')
315 ]);
316 }
317
318 $url = add_query_arg([
319 'fluent-cart' => 'early-installment-payment',
320 'subscription_hash' => $subscription->uuid,
321 ], home_url('/'));
322
323 return $this->sendSuccess([
324 'message' => __('Early payment link generated.', 'fluent-cart'),
325 'payment_url' => $url,
326 ]);
327 }
328
329 /**
330 * Run one immediate off-session charge attempt on the subscription's open
331 * renewal invoice. A card decline is HTTP 200 with status "failed" — the
332 * request worked, the charge did not. State violations are sendError 4xx.
333 */
334 public function chargeNow(Request $request, Order $order, Subscription $subscription)
335 {
336 $this->validateSubscription($subscription);
337 if ($error = $this->validateOrderBinding($order, $subscription)) return $error;
338
339 $invoice = Order::query()
340 ->where('parent_id', $subscription->parent_order_id)
341 ->where('type', Status::ORDER_TYPE_RENEWAL)
342 ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED])
343 ->orderBy('id', 'DESC')
344 ->first();
345
346 if (!$invoice) {
347 return $this->sendError([
348 'message' => __('There is no open renewal order to charge for this subscription.', 'fluent-cart')
349 ]);
350 }
351
352 $result = \FluentCart\App\Modules\Subscriptions\Services\SystemChargeService::chargeNow(
353 $invoice,
354 $subscription,
355 get_current_user_id()
356 );
357
358 if (is_wp_error($result)) {
359 return $this->sendError([
360 'message' => $result->get_error_message()
361 ]);
362 }
363
364 return $this->sendSuccess([
365 'status' => $result['status'],
366 'message' => $result['message'],
367 'subscription' => Subscription::query()->find($subscription->id)
368 ]);
369 }
370
371 public function createRenewalNow(Request $request, Order $order, Subscription $subscription)
372 {
373 $this->validateSubscription($subscription);
374 if ($error = $this->validateOrderBinding($order, $subscription)) return $error;
375
376 if (!$subscription->usesRenewalEngine()) {
377 return $this->sendError([
378 'message' => __('Creating a renewal is only available for store-billed (manual or auto-charge) subscriptions.', 'fluent-cart')
379 ]);
380 }
381
382 if (!in_array($subscription->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])) {
383 return $this->sendError([
384 'message' => __('A renewal can only be created for active or trialing subscriptions.', 'fluent-cart')
385 ]);
386 }
387
388 if (!$subscription->next_billing_date) {
389 return $this->sendError([
390 'message' => __('Subscription has no upcoming billing date.', 'fluent-cart')
391 ]);
392 }
393
394 $result = RenewalService::createRenewalOrders($subscription);
395
396 if (is_wp_error($result)) {
397 return $this->sendError([
398 'message' => $result->get_error_message()
399 ]);
400 }
401
402 $renewal = !empty($result) ? $result[0] : null;
403
404 // Renewal already exists: manual has nothing to charge; system charges
405 // the existing open renewal (one subscription per parent order).
406 if (!$renewal) {
407 if (!$subscription->isSystem()) {
408 return $this->sendError([
409 'message' => __('A pending renewal already exists for this subscription.', 'fluent-cart')
410 ], 422);
411 }
412
413 $renewal = Order::query()
414 ->where('parent_id', $subscription->parent_order_id)
415 ->where('type', Status::ORDER_TYPE_RENEWAL)
416 ->whereIn('payment_status', [Status::PAYMENT_PENDING, Status::PAYMENT_SCHEDULED])
417 ->orderBy('id', 'DESC')
418 ->first();
419
420 if (!$renewal) {
421 return $this->sendError([
422 'message' => __('A renewal already exists but is not open for charging.', 'fluent-cart')
423 ], 422);
424 }
425 }
426
427 // System charges immediately; a decline falls back to the retry/dunning
428 // flow. Manual just gets the pay-now renewal + email.
429 if ($subscription->isSystem()) {
430 $chargeResult = \FluentCart\App\Modules\Subscriptions\Services\SystemChargeService::chargeNow(
431 $renewal,
432 $subscription,
433 get_current_user_id()
434 );
435
436 if (is_wp_error($chargeResult)) {
437 return $this->sendError([
438 'message' => $chargeResult->get_error_message()
439 ]);
440 }
441
442 return $this->sendSuccess([
443 'status' => $chargeResult['status'],
444 'message' => $chargeResult['message'],
445 'subscription' => Subscription::query()->find($subscription->id)
446 ]);
447 }
448
449 return $this->sendSuccess([
450 'message' => __('Renewal has been created successfully.', 'fluent-cart'),
451 'renewal' => $renewal
452 ]);
453 }
454
455 public function skipRenewal(Request $request, Order $order, Subscription $subscription)
456 {
457 $this->validateSubscription($subscription);
458 if ($error = $this->validateOrderBinding($order, $subscription)) return $error;
459
460 if (!$subscription->usesRenewalEngine()) {
461 return $this->sendError([
462 'message' => __('Skip next period is only available for store-billed (manual or auto-charge) subscriptions.', 'fluent-cart')
463 ]);
464 }
465
466 if (!in_array($subscription->status, [Status::SUBSCRIPTION_ACTIVE, Status::SUBSCRIPTION_TRIALING])) {
467 return $this->sendError([
468 'message' => __('Period can only be skipped for active or trialing subscriptions.', 'fluent-cart')
469 ]);
470 }
471
472 if (!$subscription->next_billing_date) {
473 return $this->sendError([
474 'message' => __('Subscription has no upcoming billing date to skip.', 'fluent-cart')
475 ]);
476 }
477
478 $note = (string) $request->getSafe('reason', 'sanitize_text_field', '');
479
480 $result = RenewalService::skipNextPeriod($subscription, $note);
481
482 if (empty($result['skipped'])) {
483 // Already pending, lost a race, or nothing to advance — the no-stacking
484 // invariant is enforced inside skipNextPeriod(), so report it plainly.
485 return $this->sendError([
486 'message' => __('The next period could not be skipped. It may have already been skipped.', 'fluent-cart')
487 ]);
488 }
489
490 return $this->sendSuccess([
491 'message' => __('Billing period has been skipped successfully.', 'fluent-cart'),
492 'old_next_billing_date' => $result['old_next_billing_date'],
493 'new_next_billing_date' => $result['new_next_billing_date'],
494 ]);
495 }
496 }
497