PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.0
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Http / Controllers / OrderController.php

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

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