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

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

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