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

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