PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.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 / Http / Controllers / OrderController.php

OrderController.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.5.1, at app/Http/Controllers/OrderController.php

1,334 lines 49.5 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\Http\Controllers;
4
5
6 use FluentCart\Api\Resource\CustomerResource;
7 use FluentCart\Api\Resource\OrderResource;
8 use FluentCart\Api\StoreSettings;
9 use FluentCart\App\Events\Order\OrderBulkAction;
10 use FluentCart\App\Events\Subscription\SubscriptionActivated;
11 use FluentCart\App\Events\Order\OrderCreated;
12 use FluentCart\App\Events\Order\OrderDeleting;
13 use FluentCart\App\Events\Order\OrderDeleted;
14 use FluentCart\App\Events\Order\OrderPaid;
15 use FluentCart\App\Events\Order\OrderStatusUpdated;
16 use FluentCart\App\Events\Order\RenewalOrderDeleted;
17 use FluentCart\App\Helpers\CartHelper;
18 use FluentCart\App\Helpers\Helper;
19 use FluentCart\App\Helpers\OrderItemHelper;
20 use FluentCart\App\Helpers\Status;
21 use FluentCart\App\Helpers\StatusHelper;
22 use FluentCart\App\Http\Requests\CustomerRequest;
23 use FluentCart\App\Http\Requests\OrderRequest;
24 use FluentCart\App\Models\Customer;
25 use FluentCart\App\Models\CustomerAddresses;
26 use FluentCart\App\Models\CustomerMeta;
27 use FluentCart\App\Models\Order;
28 use FluentCart\App\Models\OrderAddress;
29 use FluentCart\App\Models\OrderItem;
30 use FluentCart\App\Models\OrderMeta;
31 use FluentCart\App\Models\OrderOperation;
32 use FluentCart\App\Models\OrderTaxRate;
33 use FluentCart\App\Models\OrderTransaction;
34 use FluentCart\App\Models\ProductVariation;
35 use FluentCart\App\Models\ShippingMethod;
36 use FluentCart\App\Models\Activity;
37 use FluentCart\App\Models\AppliedCoupon;
38 use FluentCart\App\Models\Cart;
39 use FluentCart\App\Models\LabelRelationship;
40 use FluentCart\App\Models\OrderDownloadPermission;
41 use FluentCart\App\Models\Subscription;
42 use FluentCart\App\Models\SubscriptionMeta;
43 use FluentCart\App\Services\Filter\OrderFilter;
44 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
45 use FluentCart\App\Services\Payments\PaymentHelper;
46 use FluentCart\App\Services\Reminders\ReminderService;
47 use FluentCart\App\Services\DateTime\DateTime;
48 use FluentCart\App\Services\Payments\Refund;
49 use FluentCart\App\Services\URL;
50 use FluentCart\Framework\Http\Request\Request;
51 use FluentCart\Framework\Support\Arr;
52 use FluentCart\Framework\Support\Collection;
53 use FluentCart\Framework\Validator\ValidationException;
54 use FluentCartPro\App\Hooks\Handlers\OrderActionsHandler;
55
56 class OrderController extends Controller
57 {
58 protected function getTestOrdersQuery()
59 {
60 return Order::query()
61 ->where('mode', Status::ORDER_MODE_TEST);
62 }
63
64 public function index(Request $request): \WP_REST_Response
65 {
66 $orders = OrderFilter::fromRequest($request)->paginate();
67
68 $orders = apply_filters('fluent_cart/orders_list', $orders);
69
70 return $this->sendSuccess(
71 [
72 'orders' => $orders,
73 ]
74 );
75 }
76
77 /**
78 * @throws \Exception
79 */
80 public function store(OrderRequest $request)
81 {
82 $data = $request->getSafe($request->sanitize());
83 $type = 'payment';
84 $hasSubscription = static::hasSubscription(Arr::get($data, 'order_items', []));
85 if ($hasSubscription) {
86 $type = 'subscription';
87 // right now we don't support subscription with manual order
88 $isSubscriptionAllowedInManualOrder = apply_filters('fluent_cart/order/is_subscription_allowed_in_manual_order', false, [
89 'order_items' => Arr::get($data, 'order_items', [])
90 ]);
91
92 if (!$isSubscriptionAllowedInManualOrder) {
93 return $this->sendError([
94 'message' => __('Subscription order with Manual Order is not supported yet!', 'fluent-cart')
95 ], 400);
96 }
97
98 }
99
100
101 $data['type'] = apply_filters('fluent_cart/order/type', $type, []);
102 $order = OrderResource::updatedPlaceOrder($data);
103
104
105 if (is_wp_error($order)) {
106 return $order;
107 }
108
109 // isCreated is an orderHelper instance
110 (new OrderCreated($order, null, $order->customer))->dispatch();
111
112 return $this->response->sendSuccess([
113 'message' => __('Order created successfully!', 'fluent-cart'),
114 'order_id' => $order->id,
115 'uuid' => $order->uuid
116 ]);
117 }
118
119
120 public static function hasSubscription($orderItems): bool
121 {
122 // check order items for subscription, payment_type == subscription
123 foreach ($orderItems as $item) {
124 if (Arr::get($item, 'payment_type') == 'subscription' || Arr::get($item, 'other_info.payment_type') == 'subscription') {
125 return true;
126 }
127 }
128 return false;
129 }
130
131 public function updateOrder(OrderRequest $request, $order_id)
132 {
133 $order = Order::query()->find($order_id);
134
135 if ($order->isSubscription()) {
136 return $this->sendError([
137 'message' => __('Subscription Order cannot be edited.', 'fluent-cart')
138 ], 400);
139 }
140
141
142 $requestData = $request->getSafe($request->sanitize());
143
144 $totalPaid = Arr::get($request->all(), 'total_paid');
145 $updatedTotal = Arr::get($requestData, 'total_amount');
146
147 if ($totalPaid > 0 && floatval($updatedTotal > $totalPaid)
148 && isset($requestData['payment_status'])
149 && $requestData['payment_status'] !== Status::PAYMENT_PARTIALLY_REFUNDED
150 ) {
151 $requestData['payment_status'] = Status::PAYMENT_PARTIALLY_PAID;
152 }
153
154 $status = Arr::get($requestData, 'status');
155 if ($status == Status::ORDER_COMPLETED) {
156 return $this->sendError([
157 'message' => esc_html__('Completed status can not be updated', 'fluent-cart')
158 ], 400);
159 }
160
161 // if new shipping total is already adjusted in total amount, then no need to adjust again, right now not adjusted before
162 // ToDo: adjust changed shipping total in total amount prior to this
163 $shippingTotal = Arr::get($requestData, 'shipping_total', 0);
164 $oldShippingTotal = Arr::get($order, 'shipping_total', 0);
165 if ($shippingTotal != $oldShippingTotal) {
166 $diff = $shippingTotal - $oldShippingTotal;
167 if ($diff < 0) {
168 $requestData['total_amount'] = $updatedTotal - abs($diff);
169 } else {
170 $requestData['total_amount'] = $updatedTotal + $diff;
171 }
172 }
173
174 $data = [
175 'orderData' => $requestData,
176 'deletedItems' => Arr::get($requestData, 'deletedItems', []),
177 'discount' => Arr::get($requestData, 'discount', ''),
178 'shipping' => Arr::get($requestData, 'shipping', ''),
179 'couponCalculation' => Arr::get($requestData, 'couponCalculation', []),
180 ];
181
182 $isUpdated = OrderResource::update($data, $order->id);
183
184 if (is_wp_error($isUpdated)) {
185 return $isUpdated;
186 }
187
188 return $this->response->sendSuccess($isUpdated);
189 }
190
191 public function updateOrderAddressId(Request $request, $order_id)
192 {
193 $order = Order::query()->find($order_id);
194
195
196 if (!$order) {
197 return $this->sendError([
198 'message' => __('Order not found', 'fluent-cart')
199 ], 404);
200 }
201 $data = OrderResource::updateOrderAddressId($request->only([
202 'address_id',
203 'address_type'
204 ]), $order);
205
206 if (is_wp_error($data)) {
207 return $this->sendError($data->get_error_message());
208 }
209
210 // Changing the order address recalculates tax server-side
211 // (OrderResource::updateOrderAddressId → reapplyTaxAfterUpdate). Return the
212 // refreshed order with the tax appends so the admin UI can update the tax
213 // summary, totals and payment status without a full page reload.
214 $freshOrder = Order::query()->where('id', $order_id)
215 ->addAppends([
216 'business_info',
217 'customer_tax_number',
218 'is_b2b_order',
219 'display_tax_lines',
220 'display_shipping_tax_lines',
221 'is_reverse_charge_tax_order',
222 'tax_summary',
223 ])
224 ->first();
225
226 return $this->sendSuccess([
227 'message' => 'Address updated successfully',
228 'order' => $freshOrder,
229 ]);
230 }
231
232 public function generateMissingLicenses(Request $request, Order $order)
233 {
234 if (!$order) {
235 return $this->sendError([
236 'message' => __('Order not found', 'fluent-cart')
237 ], 404);
238 }
239
240 $generatedLicenseCount = $order->licenses->count();
241 $expectedLicenseCount = apply_filters('fluent_cart/order/expected_license_count', 0, [
242 'order_items' => $order->order_items
243 ]);
244
245 if ($generatedLicenseCount >= $expectedLicenseCount) {
246 return $this->sendError([
247 'message' => __('No missing licenses found!', 'fluent-cart')
248 ], 400);
249 }
250
251 do_action('fluent_cart/order/generateMissingLicenses', ['order' => $order]);
252
253 }
254
255 /**
256 * @throws ValidationException
257 */
258 public function refundOrder(Request $request, $orderId)
259 {
260 $order = Order::query()->findOrFail($orderId);
261
262 if (!$order->canBeRefunded()) {
263 return $this->sendError([
264 'message' => __('Order can not be refunded.', 'fluent-cart')
265 ], 400);
266 }
267
268 $refundInfo = $request->get('refund_info', []);
269
270 $this->validate($refundInfo, [
271 'transaction_id' => 'required',
272 'amount' => 'required',
273 ], [
274 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'),
275 'amount.required' => __('Refund amount is required', 'fluent-cart'),
276 ]);
277
278 $transaction = OrderTransaction::query()->where('order_id', $orderId)->findOrFail($refundInfo['transaction_id']);
279 $refundAmount = Helper::toCent($refundInfo['amount']);
280
281 // refund on our end
282 $result = (new Refund())->processRefund($transaction, $refundAmount, $refundInfo);
283
284 if (is_wp_error($result)) {
285 return $this->sendError([
286 'message' => $result->get_error_message()
287 ]);
288 }
289
290 $vendorRefundId = $result['vendor_refund_id'];
291 $isManuallyRefunded = Arr::get($result, 'manual_refund.status', 'no');
292
293 $responseData = [
294 'fluent_cart_refund' => [
295 'status' => 'success',
296 'message' => __('Refund processed on FluentCart.', 'fluent-cart')
297 ],
298 ];
299
300
301 if ($isManuallyRefunded === 'yes') {
302 $message = __('Refund processed manually.', 'fluent-cart');
303 $source = Arr::get($result, 'manual_refund.source');
304 if ($source) {
305 $message = sprintf(
306 __('Refund processed manually. source: %s', 'fluent-cart'),
307 $source
308 );
309 }
310 $responseData['gateway_refund'] = [
311 'status' => 'success',
312 'message' => $message
313 ];
314 } else {
315 $responseData['gateway_refund'] = [
316 'status' => is_wp_error($vendorRefundId) ? 'failed' : 'success',
317 'message' => !is_wp_error($vendorRefundId)
318 ? sprintf(__('Refund processed on %s', 'fluent-cart'), ucfirst($transaction->payment_method))
319 : sprintf(__('ERROR processing refund on %s: %s', 'fluent-cart'), ucfirst($transaction->payment_method), $vendorRefundId->get_error_message())
320 ];
321
322 if (is_wp_error($vendorRefundId)) {
323 fluent_cart_warning_log('Refund failed on ' . ucfirst($transaction->payment_method), $vendorRefundId->get_error_message(), [
324 'module_name' => 'order',
325 'module_id' => $order->id,
326 'log_type' => 'api'
327 ]);
328 }
329 }
330
331 $cancelSubscription = Arr::get($refundInfo, 'cancelSubscription') == 'true';
332
333 if ($cancelSubscription && $transaction->subscription_id && $transaction->subscription) {
334 $vendorSubscriptionCancelled = $transaction->subscription->cancelRemoteSubscription([
335 'reason' => 'refunded',
336 'effective_from' => 'immediately'
337 ]);
338 if (is_wp_error($vendorSubscriptionCancelled)) {
339 $responseData['subscription_cancel']['status'] = 'failed';
340 $responseData['subscription_cancel']['message'] = $vendorSubscriptionCancelled->get_error_message();
341 } else {
342 $vendorResult = $vendorSubscriptionCancelled['vendor_result'];
343 $responseData['subscription_cancel']['status'] = is_wp_error($vendorResult) ? 'failed' : 'success';
344 $responseData['subscription_cancel']['message'] = is_wp_error($vendorResult)
345 ? $vendorResult->get_error_message()
346 : __('Subscription cancelled successfully', 'fluent-cart');
347 }
348 }
349
350 return $this->sendSuccess(
351 $responseData
352 );
353 }
354
355
356 public function createAndChangeCustomer(CustomerRequest $request, $order_id)
357 {
358
359 $data = $request->getSafe($request->sanitize());
360 $isCreated = CustomerResource::create($data);
361
362 if (is_wp_error($isCreated)) {
363 return $this->sendError(
364 [
365 'message' => __('Failed to attach customer', 'fluent-cart')
366 ]
367 );
368 }
369
370 $customerId = Arr::get($isCreated, 'data.id');
371
372 $isChanged = $this->updateOrderCustomer($customerId, $order_id);
373 if (is_wp_error($isChanged)) {
374 return $this->sendError(
375 [
376 'message' => $isChanged->get_error_message()
377 ]
378 );
379 }
380 return $this->sendSuccess($isChanged);
381
382 }
383
384 public function changeCustomer(Request $request, $order_id)
385 {
386 $customerId = $request->get('customer_id');
387 $customerId = sanitize_text_field($customerId);
388
389 if (!$customerId) {
390 return $this->sendError([
391 'message' => __('Customer id is required', 'fluent-cart')
392 ], 423);
393 }
394
395 $isChanged = $this->updateOrderCustomer($customerId, $order_id);
396 if (is_wp_error($isChanged)) {
397 return $this->sendError(
398 [
399 'message' => $isChanged->get_error_message()
400 ]
401 );
402 }
403 return $this->sendSuccess($isChanged);
404
405 }
406
407 private function updateOrderCustomer($customerId, $orderId)
408 {
409
410 /**
411 * 1. Check if it's a different customer
412 * 2. Update main order.customer_id
413 * 3. update subscription.customer_id
414 * 4. update license.customer_id
415 *
416 *
417 * // critical thinking
418 * 5. If it's a renewal then change the parent order's data as well it's all child resources
419 * 6. Recount Customer's stat (New as well as the old one!)
420 */
421
422 $order = Order::query()->findOrFail($orderId);
423
424 if ($order->customer_id == $customerId) {
425 return [
426 'message' => __('Customer is already attached to this order', 'fluent-cart')
427 ];
428 }
429 $newCustomer = Customer::query()->findOrFail($customerId);
430 $oldCustomerId = $order->customer_id;
431
432 CustomerAddresses::query()->where('customer_id', $oldCustomerId)->update(['customer_id' => $customerId]);
433 CustomerMeta::query()->where('customer_id', $oldCustomerId)->update(['customer_id' => $customerId]);
434
435 $connectedOrderIds = [$order->id];
436 if ($order->parent_id && $order->type == 'renewal') {
437 $connectedOrderIds[] = $order->parent_id;
438 $parentOrderIdsOrders = Order::query()->where('parent_id', $order->parent_id)->get()->pluck('id')->toArray();
439 $connectedOrderIds = array_merge($parentOrderIdsOrders, $connectedOrderIds);
440
441 } else if ($order->type == 'subscription') {
442 $childOrderIds = Order::query()->where('parent_id', $order->id)->pluck('id')->toArray();
443 $connectedOrderIds = array_merge($childOrderIds, $connectedOrderIds);
444 }
445 Order::query()->whereIn('id', $connectedOrderIds)->update(['customer_id' => $customerId]);
446 Subscription::query()->whereIn('parent_order_id', $connectedOrderIds)->update(['customer_id' => $customerId]);
447
448 $newCustomer->recountStat();
449 $oldCustomer = Customer::query()->find($oldCustomerId);
450 if (!empty($oldCustomer)) {
451 $oldCustomer->recountStat();
452 }
453
454 do_action('fluent_cart/order_customer_changed', [
455 'order' => $order,
456 'old_customer' => $oldCustomer,
457 'new_customer' => $newCustomer,
458 'connected_order_ids' => $connectedOrderIds
459 ]);
460
461 fluent_cart_success_log(
462 __('Customer changed', 'fluent-cart'),
463 sprintf(
464 /* translators: 1: old customer name, 2: new customer name */
465 __('Customer changed from %1$s to %2$s', 'fluent-cart'), $oldCustomer->full_name, $newCustomer->full_name),
466 [
467 'module_name' => 'order',
468 'module_id' => $orderId,
469 'log_type' => 'activity'
470 ]);
471
472 return [
473 'message' => __('Customer changed successfully', 'fluent-cart')
474 ];
475 }
476
477 public function deleteOrder(Request $request, $order_id)
478 {
479
480 $order = Order::query()->find($order_id);
481
482 if (empty($order)) {
483 return $this->sendError([
484 'message' => __('Order not found', 'fluent-cart'),
485 'data' => [
486 'order_id' => $order_id,
487 'status' => 'error'
488 ],
489 'errors' => []
490 ], 404);
491 }
492
493 $order_id = $order->id; // Get the single order ID
494 // Find the order with additional details
495
496 $canBeDeleted = $order->canBeDeleted();
497 if (is_wp_error($canBeDeleted)) {
498
499 return $this->sendError([
500 'message' => $canBeDeleted->get_error_message(),
501 'data' => [
502 'order_id' => $order_id,
503 'invoice_no' => $order->invoice_no,
504 'status' => 'error',
505 'reason' => $canBeDeleted->get_error_code()
506 ],
507 'errors' => [
508 $canBeDeleted->get_error_message()
509 ]
510 ], 400);
511 }
512
513
514 $DB = \FluentCart\App\App::db();
515 $connectedOrderIds = [$order->id];
516 $isTestMode = $order->mode === Status::ORDER_MODE_TEST;
517
518 if ($order->type === 'subscription') {
519 $childOrderIds = Order::query()->where('parent_id', $order->id)->pluck('id')->toArray();
520 $connectedOrderIds = array_merge($childOrderIds, $connectedOrderIds);
521 }
522
523 try {
524 $DB->beginTransaction();
525
526 if ($order->type === 'subscription') {
527 $subscriptionIds = Subscription::query()->whereIn('parent_order_id', $connectedOrderIds)->pluck('id')->toArray();
528 if ($subscriptionIds) {
529 SubscriptionMeta::query()->whereIn('subscription_id', $subscriptionIds)->delete();
530 }
531
532 Subscription::query()->whereIn('parent_order_id', $connectedOrderIds)->delete();
533 }
534
535 // Dispatch inside transaction so stock restore is atomic with deletion.
536 // Must run before deleteOrderRelatedData() which removes stock_movement meta and order items.
537 (new OrderDeleting($order, $connectedOrderIds, $isTestMode, $order->type))->dispatch();
538
539 // Pre-load relations before cleanup so the delete events have address data
540 $order->load(['customer', 'shipping_address', 'billing_address']);
541
542 $this->deleteOrderRelatedData($connectedOrderIds, $isTestMode);
543 $DB->commit();
544 } catch (\Exception $e) {
545 $DB->rollBack();
546 return $this->sendError([
547 'message' => __('Failed to delete order', 'fluent-cart'),
548 ], 400);
549 }
550
551 if ($order->type === 'renewal') {
552 (new RenewalOrderDeleted($order))->dispatch();
553 } else {
554 (new OrderDeleted($order, $connectedOrderIds))->dispatch();
555 }
556
557 return $this->sendSuccess([
558 'message' => sprintf(
559 /* translators: %s is the order/invoice number */
560 __('Order %s deleted successfully', 'fluent-cart'), $order_id),
561 'data' => [
562 'order_id' => $order_id,
563 'invoice_no' => $order->invoice_no,
564 'status' => 'success'
565 ],
566 'errors' => []
567 ]);
568
569 }
570
571 /**
572 * Delete order related data (transactions, items, meta, addresses, orders)
573 */
574 private function deleteOrderRelatedData(array $orderIds, bool $isTestMode = false): void
575 {
576 OrderTransaction::query()->whereIn('order_id', $orderIds)->delete();
577 OrderAddress::query()->whereIn('order_id', $orderIds)->delete();
578 OrderItem::query()->whereIn('order_id', $orderIds)->delete();
579 OrderMeta::query()->whereIn('order_id', $orderIds)->delete();
580 OrderTaxRate::query()->whereIn('order_id', $orderIds)->delete();
581 OrderOperation::query()->whereIn('order_id', $orderIds)->delete();
582 AppliedCoupon::query()->whereIn('order_id', $orderIds)->delete();
583 Cart::query()->whereIn('order_id', $orderIds)->delete();
584 OrderDownloadPermission::query()->whereIn('order_id', $orderIds)->delete();
585 LabelRelationship::query()->where('labelable_type', Order::class)
586 ->whereIn('labelable_id', $orderIds)->delete();
587
588 if ($isTestMode) {
589 Activity::query()->where('module_type', Order::class)
590 ->whereIn('module_id', $orderIds)->delete();
591 }
592
593 Order::query()->whereIn('id', $orderIds)->delete();
594 }
595
596
597 public function getDetails($orderId)
598 {
599 $data = OrderResource::view($orderId);
600
601 if (is_wp_error($data) || empty($data['order'])) {
602 return $this->entityNotFoundError(
603 __('Order not found', 'fluent-cart'),
604 __('Back to orders', 'fluent-cart'),
605 '/orders'
606 );
607 }
608
609 $data['order'] = apply_filters('fluent_cart/order/view', $data['order'], []);
610
611 // check if the order has generated license
612 $data['order']['has_missing_licenses'] = false;
613
614 $expectedLicenseCount = apply_filters('fluent_cart/order/expected_license_count', 0, [
615 'order_items' => Arr::get($data, 'order.order_items', [])
616 ]);
617
618 $generatedLicenseCount = count(Arr::get($data, 'order.licenses', []));
619 if ($expectedLicenseCount && ($expectedLicenseCount > $generatedLicenseCount)) {
620 $data['order']['has_missing_licenses'] = true;
621 }
622
623 $data['order']['order_operation'] = OrderOperation::query()->where('order_id', $orderId)
624 ->first();
625
626 $url = URL::appendQueryParams(
627 (new StoreSettings())->getReceiptPage(),
628 [
629 'order_hash' => Arr::get($data, 'order.uuid')
630 ]
631 );
632
633 if (empty($data['order']['receipt_url'])) {
634 $data['order']['receipt_url'] = $url;
635 }
636 $taxNumber = Arr::get($data, 'order.customer_tax_number', '');
637 if (!empty($taxNumber)) {
638 $data['tax_id'] = $taxNumber;
639 }
640 unset($data['order']['customer_tax_number']);
641
642 $data['can_send_payment_reminder'] = (new ReminderService())->canSendPaymentReminder($data['order']);
643
644 return $data;
645 }
646
647 public function createCustom(Request $request, OrderItemHelper $orderItemHelper, Order $order)
648 {
649 try {
650 return $orderItemHelper->processCustom(
651 $request->product,
652 $order->id
653 );
654
655 } catch (\Exception $e) {
656 return $this->sendError([
657 'message' => $e->getMessage()
658 ], 423);
659 }
660
661 }
662
663 // public function calculate(Request $request, OrderHelper $orderHelper)
664 // {
665 // return $orderHelper->calculate($request->order);
666 // }
667
668 public function updateStatuses(Request $request, Order $order)
669 {
670
671 $data = [
672 'order' => $order,
673 'statuses' => $request->get('statuses', []),
674 'manage_stock' => $request->get('manage_stock'),
675 'action' => $request->get('action')
676 ];
677 $isUpdated = OrderResource::updateStatuses($data);
678
679 if (is_wp_error($isUpdated)) {
680 return $isUpdated;
681 }
682 return $this->response->sendSuccess($isUpdated);
683 }
684
685 public function updateOrderAddress(Request $request, $orderId, $addressId)
686 {
687
688 $data = $request->all();
689
690 $isUpdated = OrderResource::updateOrderAddress($data);
691
692 if (is_wp_error($isUpdated)) {
693 return $isUpdated;
694 }
695 return $this->response->sendSuccess($isUpdated);
696 }
697
698
699 public function markAsPaid(Request $request, Order $order)
700 {
701 $dueAmount = intval($order->total_amount - $order->total_paid);
702
703 if ($dueAmount <= 0) {
704 return $this->sendError([
705 'message' => __('Order has already been paid', 'fluent-cart')
706 ], 423);
707 }
708
709 if (Arr::get($order, 'status') === 'canceled') {
710 return $this->sendError([
711 'message' => __('Unable to mark paid for canceled order', 'fluent-cart')
712 ], 423);
713 }
714
715 $transaction = $order->transactions->where('status', Status::TRANSACTION_PENDING)
716 ->where('payment_method', 'offline_payment')
717 ->first();
718
719 $newTransactionData = [
720 'total' => $dueAmount,
721 'status' => Status::TRANSACTION_SUCCEEDED,
722 'payment_method' => sanitize_text_field($request->payment_method),
723 'vendor_charge_id' => sanitize_text_field($request->vendor_charge_id),
724 'payment_mode' => sanitize_text_field($order->mode),
725 'payment_method_type' => sanitize_text_field($request->payment_method),
726 'order_type' => sanitize_text_field($order->type),
727 'transaction_type' => sanitize_text_field($request->transaction_type),
728 'currency' => sanitize_text_field($order->currency),
729 ];
730
731 if ($transaction) {
732 $transaction->update($newTransactionData);
733 } else {
734 $transaction = OrderTransaction::query()->create(
735 array_merge($newTransactionData, [
736 'order_id' => $order->id
737 ])
738 );
739 }
740
741 $order->note = sanitize_text_field($request->get('mark_paid_note', ''));
742
743 $oldStatus = $order->status;
744
745 if ($order->payment_status !== 'partially_refunded') {
746 $order->payment_status = Status::PAYMENT_PAID;
747 }
748
749 $order->status = Status::ORDER_PROCESSING;
750 $order->total_paid = $order->total_amount;
751 $order->save();
752
753 $actionActivity = [
754 'title' => __('Order status updated', 'fluent-cart'),
755 'content' => sprintf(
756 /* translators: 1: old status, 2: new status */
757 __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), $oldStatus, $order->status)
758 ];
759
760 // dispatching events related to order status update and payment paid
761 (new OrderPaid($order, $order->customer, $transaction))->dispatch();
762
763 (new OrderStatusUpdated($order, $oldStatus, $order->status, true, $actionActivity, 'order_status'))->dispatch();
764
765 if ($order->type === 'subscription') {
766 $subscription = Subscription::query()->where('parent_order_id', $order->id)->first();
767 if ($subscription) {
768 $oldSubStatus = $subscription->status;
769 $subscription = SubscriptionService::syncSubscriptionStates($subscription, ['status' => Status::SUBSCRIPTION_ACTIVE]);
770 if ($oldSubStatus !== Status::SUBSCRIPTION_ACTIVE && $subscription->status === Status::SUBSCRIPTION_ACTIVE) {
771 (new SubscriptionActivated($subscription, $order, $order->customer))->dispatch();
772 }
773 }
774 }
775
776 // if digital
777 if ($order->fulfillment_type == 'digital' && $order->status === Status::ORDER_PROCESSING) {
778 $order->status = Status::ORDER_COMPLETED;
779 $order->completed_at = DateTime::gmtNow();
780 $order->save();
781
782 $actionActivity = [
783 'title' => __('Order status updated', 'fluent-cart'),
784 'content' => sprintf(
785 /* translators: 1: old status, 2: new status */
786 __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), Status::ORDER_PROCESSING, $order->status)
787 ];
788
789 (new OrderStatusUpdated($order, Status::ORDER_PROCESSING, $order->status, true, $actionActivity, 'order_status'))->dispatch();
790 }
791
792 return $this->response->sendSuccess([
793 'message' => __('Order has been marked as paid', 'fluent-cart')
794 ]);
795 }
796
797 public function handleBulkActions(Request $request)
798 {
799
800 $action = sanitize_text_field($request->get('action', ''));
801 $orderIds = $request->get('order_ids', []);
802
803 if ($action == 'delete_test_orders') {
804 return $this->handleDeleteTestOrdersBulkAction($request);
805 }
806
807 $orderIds = array_map(function ($id) {
808 return (int)$id;
809 }, $orderIds);
810
811 $orderIds = array_filter($orderIds);
812
813 if (!$orderIds) {
814 return $this->sendError([
815 'message' => __('Orders selection is required', 'fluent-cart')
816 ]);
817 }
818
819 $orders = Order::query()->whereIn('id', $orderIds)->get();
820
821
822 if ($action == 'delete_orders') {
823
824 $isDeleted = OrderResource::bulkDeleteByOrderIds($orderIds);
825
826 if (is_wp_error($isDeleted)) {
827 return $isDeleted;
828 }
829 return $this->response->sendSuccess($isDeleted);
830
831 // $DB = App::db();
832 // $DB->beginTransaction();
833
834 // try {
835
836 // OrderResource::bulkDeleteByOrderIds($orderIds);
837 // OrderTransaction::bulkDeleteByOrderIds($orderIds);
838 // OrderMetaResource::bulkDeleteByOrderIds($orderIds);
839 // OrderItemResource::bulkDeleteByOrderIds($orderIds);
840
841 // $DB->commit();
842
843 // return [
844 // 'message' => __('Selected orders and their associated resources have been deleted permanently', 'fluent-cart')
845 // ];
846 // } catch (\Exception $e) {
847 // $DB->rollBack();
848 // return static::makeErrorResponse([
849 // ['code' => 400, 'message' => __('Failed to delete orders and their associated resources', 'fluent-cart')]
850 // ]);
851 // }
852
853
854 }
855 if ($action == 'change_shipping_status') {
856 $newStatus = sanitize_text_field($request->get('new_status', ''));
857 if (!$newStatus) {
858 return $this->sendError([
859 'message' => __('Please select status', 'fluent-cart')
860 ]);
861 }
862
863 $validStatuses = Helper::getShippingStatuses();
864 if (!isset($validStatuses[$newStatus])) {
865 return $this->sendError([
866 'message' => __('Provided shipping status is not valid', 'fluent-cart')
867 ]);
868 }
869
870 // foreach ($orders as $order) {
871 // $order->updateShippingStatus($newStatus);
872 // }
873
874 return [
875 'message' => __('Shipping Status has been changed for the selected orders', 'fluent-cart')
876 ];
877
878 }
879 if ($action == 'change_order_status') {
880
881 $newStatus = sanitize_text_field($request->get('new_status', ''));
882 if (!$newStatus) {
883 return $this->sendError([
884 'message' => __('Please select status', 'fluent-cart')
885 ]);
886 }
887
888 $validStatuses = Status::getEditableOrderStatuses();
889 if (!isset($validStatuses[$newStatus])) {
890 return $this->sendError([
891 'message' => __('Provided order status is not valid', 'fluent-cart')
892 ]);
893 }
894
895 $failedOrderIds = [];
896 $updatedOrderIds = [];
897
898 foreach ($orders as $order) {
899 // $order->updateStatus('status', $newStatus);
900 $isUpdated = OrderResource::updateStatuses([
901 'order' => $order,
902 'action' => 'change_order_status',
903 'statuses.order_status' => $newStatus,
904 'manage_stock' => sanitize_text_field($request->get('manage_stock')),
905 ]);
906
907 if (is_wp_error($isUpdated)) {
908 $failedOrderIds[] = $order->id;
909 } else {
910 $updatedOrderIds[] = $order->id;
911 }
912 }
913
914 if (count($failedOrderIds) > 0) {
915 $failedOrderIds = implode(' , ', $failedOrderIds);
916 return count($updatedOrderIds) > 0
917 ? $this->sendSuccess([
918 'message' => sprintf(
919 /* translators: %s is the order ids */
920 __("The order ID - %s cannot be updated because they are either already cancelled or have the same status. And remaining order status has been successfully changed", 'fluent-cart'), $failedOrderIds)
921 ])
922 :
923 $this->sendError([
924 'message' => sprintf(
925 /* translators: %s is the order ids */
926 __("The order ID - %s cannot be updated because they are either already cancelled or have the same status.", 'fluent-cart'), $failedOrderIds)
927 ], 423);
928 }
929
930 if (count($updatedOrderIds) > 0 && count($failedOrderIds) < 1) {
931 return $this->sendSuccess([
932 'message' => __('Order Status has been changed for the selected orders', 'fluent-cart')
933 ]);
934 }
935 }
936
937 if ($action == 'capture_payments') {
938 foreach ($orders as $order) {
939 $order->capturePayments();
940 }
941
942 return [
943 'message' => __('Selected payments has been successfully captured', 'fluent-cart')
944 ];
945 }
946
947 if ($action == 'change_payment_status') {
948 $newStatus = sanitize_text_field($request->get('new_status', ''));
949 if (!$newStatus) {
950 return $this->sendError([
951 'message' => __('Please select status', 'fluent-cart')
952 ]);
953 }
954
955 $validStatuses = Status::getEditableTransactionStatuses();
956 if (!isset($validStatuses[$newStatus])) {
957 return $this->sendError([
958 'message' => __('Provided payment status is not valid', 'fluent-cart')
959 ]);
960 }
961
962 $failedOrderIds = [];
963 $updatedOrderIds = [];
964 $count = 0;
965 $customerIds = [];
966
967 foreach ($orders as $order) {
968 $transaction = $order->latest_transaction;
969 $isUpdated = OrderResource::updatePaymentStatus([
970 'order' => $order,
971 'status' => $newStatus,
972 'transaction' => $transaction,
973 ]);
974
975 if (is_wp_error($isUpdated)) {
976 $failedOrderIds[] = $order->id;
977 } else {
978 $updatedOrderIds[] = $order->id;
979 $count++;
980 $customerIds[] = $order->customer_id;
981 }
982 }
983
984 if ($count > 0 && count($customerIds) > 0) {
985 (new OrderBulkAction($customerIds))->dispatch();
986 }
987
988 if (count($failedOrderIds) > 0) {
989 $failedOrderIds = implode(' , ', $failedOrderIds);
990 return count($updatedOrderIds) > 0
991 ? $this->sendSuccess([
992 'message' => sprintf(
993 /* translators: %s is the order ids */
994 __("The order ID - %s cannot be updated at the moment because the transaction either already has the same status or does not match the provided order. The remaining order statuses have been updated successfully.", 'fluent-cart'), $failedOrderIds)
995 ])
996 :
997 $this->sendError([
998 'message' => sprintf(
999 /* translators: %s is the order ids */
1000 __("The order ID - %s cannot be updated at the moment because its payment status is either the same as before or has already been refunded.", 'fluent-cart'), $failedOrderIds)
1001 ], 423);
1002 }
1003
1004 if (count($updatedOrderIds) > 0 && count($failedOrderIds) < 1) {
1005 return $this->sendSuccess([
1006 'message' => sprintf(
1007 /* translators: %s is the payment status */
1008 __("Selected orders payment status has been marked as %s", 'fluent-cart'),
1009 $newStatus
1010 )
1011 ]);
1012 }
1013 }
1014
1015 return $this->sendError([
1016 'message' => __('Selected action is invalid', 'fluent-cart')
1017 ]);
1018
1019 }
1020
1021 protected function handleDeleteTestOrdersBulkAction(Request $request)
1022 {
1023 $batchSize = max(1, (int)apply_filters('fluent_cart/order/delete_test_orders_batch_size', 50));
1024 $lastOrderId = max(0, (int)$request->get('last_order_id', 0));
1025 $totalCount = (int)$this->getTestOrdersQuery()->count();
1026
1027 $testOrders = $this->getTestOrdersQuery()
1028 ->select('id')
1029 ->orderBy('id')
1030 ->when($lastOrderId > 0, function ($query) use ($lastOrderId) {
1031 return $query->where('id', '>', $lastOrderId);
1032 })
1033 ->limit($batchSize)
1034 ->get();
1035
1036 $batchOrderIds = $testOrders->pluck('id')->map(function ($id) {
1037 return (int)$id;
1038 })->toArray();
1039
1040 if (!$batchOrderIds) {
1041 return $this->sendSuccess([
1042 'message' => $lastOrderId
1043 ? __('Test order deletion completed.', 'fluent-cart')
1044 : __('No test orders found to delete', 'fluent-cart'),
1045 'total_count' => $totalCount,
1046 'batch_size' => $batchSize,
1047 'batch_count' => 0,
1048 'deleted_count' => 0,
1049 'failed_count' => 0,
1050 'deleted_order_ids' => [],
1051 'failed_order_ids' => [],
1052 'last_attempted_order_id' => $lastOrderId,
1053 'has_more' => false
1054 ]);
1055 }
1056
1057 $isDeleted = OrderResource::bulkDeleteByOrderIds($batchOrderIds);
1058
1059 if (is_wp_error($isDeleted)) {
1060 $deletedOrderIds = [];
1061 $failedOrderIds = $batchOrderIds;
1062 } else {
1063 $deletedOrderIds = array_values(array_unique(array_map('intval', Arr::get($isDeleted, 'data.deleted_order_ids', []))));
1064 $failedOrderIds = array_values(array_unique(array_map('intval', Arr::get($isDeleted, 'data.failed_order_ids', []))));
1065 }
1066
1067 $deletedCount = count($deletedOrderIds);
1068 $failedCount = count($failedOrderIds);
1069 $lastAttemptedOrderId = (int)end($batchOrderIds);
1070 $hasMore = $this->getTestOrdersQuery()
1071 ->where('id', '>', $lastAttemptedOrderId)
1072 ->exists();
1073
1074 return $this->sendSuccess([
1075 'message' => $hasMore
1076 ? __('Deleting test orders...', 'fluent-cart')
1077 : __('Test order deletion completed.', 'fluent-cart'),
1078 'total_count' => $totalCount,
1079 'batch_size' => $batchSize,
1080 'batch_count' => count($batchOrderIds),
1081 'deleted_count' => $deletedCount,
1082 'failed_count' => $failedCount,
1083 'deleted_order_ids' => $deletedOrderIds,
1084 'failed_order_ids' => $failedOrderIds,
1085 'last_attempted_order_id' => $lastAttemptedOrderId,
1086 'has_more' => $hasMore
1087 ]);
1088 }
1089
1090 public function updateTransactionStatus(Request $request, $order, OrderTransaction $transaction)
1091 {
1092
1093 $order = Order::query()->find($order);
1094 $newStatus = sanitize_text_field($request->get('status', ''));
1095
1096 $validStatuses = Status::getEditableTransactionStatuses();
1097 if (!isset($validStatuses[$newStatus])) {
1098 return $this->sendError([
1099 'message' => __('Provided transaction status is not valid', 'fluent-cart')
1100 ]);
1101 }
1102
1103 if ($transaction->status == $newStatus) {
1104 return $this->sendError([
1105 'reload' => true,
1106 'message' => __('Transaction already has the same status', 'fluent-cart')
1107 ]);
1108 }
1109
1110 if ($transaction->order_id != $order->id) {
1111 return $this->sendError([
1112 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart')
1113 ]);
1114 }
1115
1116 $transaction->updateStatus($newStatus);
1117 $order->updatePaymentStatus($newStatus);
1118
1119 return [
1120 'transaction' => $transaction,
1121 'message' => __('Payment status has been successfully updated', 'fluent-cart')
1122 ];
1123 }
1124
1125 public function getStats($orderUuid): \WP_REST_Response
1126 {
1127 $order = OrderResource::find($orderUuid);
1128 return $this->sendSuccess([
1129 'widgets' => apply_filters('fluent_cart/widgets/single_order', [], $order)
1130 ]);
1131 }
1132
1133 public function getShippingMethods(Request $request): \WP_REST_Response
1134 {
1135 $countryCode = $request->get('country_code');
1136 $state = $request->get('state') ?? '';
1137 $orderItems = $this->prepareOrderItemsWithVariations($request->get('order_items'));
1138
1139 $enabledMethods = $this->getEnabledShippingMethodsWithCharges($orderItems);
1140
1141 if (empty($countryCode)) {
1142 return $this->sendSuccess([
1143 'shipping_methods' => [],
1144 'other_shipping_methods' => $enabledMethods,
1145 ]);
1146 }
1147
1148 $applicableMethods = ShippingMethod::getApplicableForCountry($countryCode, $state);
1149
1150 $applicableIds = $applicableMethods->pluck('id')->toArray();
1151
1152 return $this->sendSuccess([
1153 'shipping_methods' => $enabledMethods->whereIn('id', $applicableIds)->values(),
1154 'other_shipping_methods' => $enabledMethods->whereNotIn('id', $applicableIds)->values(),
1155 ]);
1156 }
1157
1158 protected function getEnabledShippingMethodsWithCharges(array $orderItems)
1159 {
1160 return ShippingMethod::query()
1161 ->where('is_enabled', '1')
1162 ->get()
1163 ->each(function ($method) use ($orderItems) {
1164 $method->shipping_charge = CartHelper::calculateShippingMethodCharge($method, $orderItems);
1165 });
1166 }
1167
1168 public function updateShipping(Request $request)
1169 {
1170 $orderItems = $request->get('order_items');
1171 $shippingMethodId = $request->get('shipping_id');
1172
1173 $orderItems = $this->prepareOrderItemsWithVariations($orderItems);
1174
1175
1176 $method = ShippingMethod::query()->find($shippingMethodId);
1177
1178 $totalShippingCharge = CartHelper::calculateShippingMethodCharge($method, $orderItems);
1179
1180 return $this->sendSuccess([
1181 'message' => __('Shipping updated', 'fluent-cart'),
1182 'shipping_charge' => $totalShippingCharge,
1183 'order_items' => $orderItems
1184 ]);
1185
1186 }
1187
1188 /**
1189 * Calculate tax for an admin order given an address and item list.
1190 * No DB writes — pure calculation helper.
1191 *
1192 * Accepts: { country, state, city, postcode, items: [{post_id, object_id, subtotal, discount_total}] }
1193 * Returns: { tax_total, shipping_tax, tax_lines, tax_behavior, tax_country }
1194 */
1195 public function calculateTax(Request $request)
1196 {
1197 $address = [
1198 'country' => sanitize_text_field($request->get('country', '')),
1199 'state' => sanitize_text_field($request->get('state', '')),
1200 'city' => sanitize_text_field($request->get('city', '')),
1201 'postcode' => sanitize_text_field($request->get('postcode', '')),
1202 ];
1203
1204 $rawItems = $request->get('items', []);
1205 if (!is_array($rawItems)) {
1206 $rawItems = [];
1207 } elseif (count($rawItems) > 100) {
1208 $rawItems = array_slice($rawItems, 0, 100);
1209 }
1210
1211 $items = [];
1212 foreach ($rawItems as $item) {
1213 $items[] = [
1214 'post_id' => (int) Arr::get($item, 'post_id', 0),
1215 'object_id' => (int) Arr::get($item, 'object_id', 0),
1216 'subtotal' => (int) Arr::get($item, 'subtotal', 0),
1217 'discount_total' => (int) Arr::get($item, 'discount_total', 0),
1218 'shipping_charge' => (int) Arr::get($item, 'shipping_charge', 0),
1219 'quantity' => max(1, (int) Arr::get($item, 'quantity', 1)),
1220 ];
1221 }
1222
1223 $result = \FluentCart\App\Services\Tax\AdminOrderTaxService::calculate($items, $address);
1224
1225 if ($result === null) {
1226 return $this->sendSuccess([
1227 'tax_total' => 0,
1228 'shipping_tax' => 0,
1229 'tax_lines' => [],
1230 'tax_behavior' => 0,
1231 'tax_country' => '',
1232 ]);
1233 }
1234
1235 $taxLines = Arr::get($result, 'tax_lines', []);
1236 $strippedTaxLines = array_values(array_map(function ($line) {
1237 return [
1238 'label' => Arr::get($line, 'label', ''),
1239 'rate_percent' => Arr::get($line, 'rate_percent', 0),
1240 'tax_amount' => (int) Arr::get($line, 'tax_amount', 0),
1241 'inclusive' => (bool) Arr::get($line, 'inclusive', false),
1242 ];
1243 }, $taxLines));
1244
1245 return $this->sendSuccess([
1246 'tax_total' => (int) Arr::get($result, 'tax_total', 0),
1247 'shipping_tax' => (int) Arr::get($result, 'shipping_tax', 0),
1248 'tax_behavior' => (int) Arr::get($result, 'tax_behavior', 0),
1249 'tax_country' => Arr::get($result, 'tax_country', ''),
1250 'tax_lines' => $strippedTaxLines,
1251 ]);
1252 }
1253
1254 protected function prepareOrderItemsWithVariations($orderItems)
1255 {
1256 $itemCollection = (new Collection($orderItems))->keyBy('id');
1257 $ids = $itemCollection->keys()->toArray();
1258 $variations = ProductVariation::query()->with('shippingClass')->whereIn('id', $ids)->get();
1259 $orderItems = $itemCollection->toArray();
1260
1261 foreach ($variations as &$variation) {
1262 $shippingCharge = CartHelper::calculateShippingCharge($variation, $itemCollection->get($variation->id)['quantity']);
1263 $variation->quantity = Arr::get($orderItems, $variation->id . '.' . 'quantity', 1);
1264 $variation->discount_total = Arr::get($orderItems, $variation->id . '.' . 'discount_total', 0);
1265 $variation->shipping_charge = $shippingCharge;
1266 $variation->unit_price = $variation->item_price;
1267
1268 }
1269
1270 $orderItems = $variations->mapWithKeys(function ($item) {
1271 return [
1272 $item->id => [
1273 'id' => $item->id,
1274 'quantity' => $item->quantity,
1275 'shipping_charge' => $item->shipping_charge,
1276 'unit_price' => $item->unit_price,
1277 'other_info' => $item->other_info,
1278 'discount_total' => $item->discount_total,
1279 'fulfillment_type' => $item->fulfillment_type,
1280 ]
1281 ];
1282 });
1283 $orderItems = $orderItems->toArray();
1284
1285 return $orderItems;
1286 }
1287
1288 public function acceptDispute(Request $request, $order, $transaction)
1289 {
1290 $order = Order::query()->find($order);
1291 $transaction = OrderTransaction::query()->find($transaction);
1292 $response = $transaction->acceptDispute([
1293 'dispute_note' => $request->getSafe('dispute_note', 'sanitize_text_field'),
1294 ]);
1295
1296 if (is_wp_error($response)) {
1297 return $this->sendError([
1298 'message' => $response->get_error_message()
1299 ]);
1300 }
1301
1302 return $this->sendSuccess([
1303 'message' => __('Dispute accepted!', 'fluent-cart')
1304 ]);
1305 }
1306
1307 public function syncOrderStatuses(Request $request, Order $order)
1308 {
1309 $latestTransaction = OrderTransaction::query()
1310 ->where('order_id', $order->id)
1311 ->orderBy('id', 'desc')
1312 ->first();
1313
1314 if (!$latestTransaction) {
1315 return $this->sendError([
1316 'message' => __('No transaction found for this order', 'fluent-cart')
1317 ], 404);
1318 }
1319
1320 (new StatusHelper($order))->syncOrderStatuses($latestTransaction);
1321
1322 // Reload order to get updated data
1323 $order = Order::query()->find($order->id);
1324
1325 return $this->sendSuccess([
1326 'message' => __('Order statuses synced successfully', 'fluent-cart'),
1327 'order' => $order,
1328 'payment_status' => $order->payment_status,
1329 'status' => $order->status,
1330 ]);
1331 }
1332
1333 }
1334