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

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