PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.1
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.1, at app/Modules/Subscriptions/Http/Controllers/SubscriptionController.php

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