PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Http / Controllers / OrderController.php

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

1,352 lines 49.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 * 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 $db = Order::query()->getConnection();
831 $db->beginTransaction();
832
833 try {
834 $locked = Order::query()
835 ->where('id', $order->id)
836 ->lockForUpdate()
837 ->first();
838
839 if (!$locked) {
840 $db->rollBack();
841 return $this->sendError([
842 'message' => __('Order not found', 'fluent-cart')
843 ], 404);
844 }
845
846 $dueAmount = intval($locked->total_amount - $locked->total_paid);
847
848 if ($dueAmount <= 0) {
849 $db->rollBack();
850 return $this->sendError([
851 'message' => __('Order has already been paid', 'fluent-cart')
852 ], 423);
853 }
854
855 if ($locked->status === Status::ORDER_CANCELED) {
856 $db->rollBack();
857 return $this->sendError([
858 'message' => __('Unable to mark paid for canceled order', 'fluent-cart')
859 ], 423);
860 }
861
862 // Reuse an existing pending transaction without vendor_charge_id instead of
863 // creating a new one. Queried fresh (not via the route-bound relation) so it
864 // reflects the state under the lock.
865 $transaction = OrderTransaction::query()
866 ->where('order_id', $locked->id)
867 ->where('status', Status::TRANSACTION_PENDING)
868 ->where(function ($query) {
869 $query->whereNull('vendor_charge_id')
870 ->orWhere('vendor_charge_id', '');
871 })
872 ->orderBy('id', 'asc')
873 ->lockForUpdate()
874 ->first();
875
876 $newTransactionData = [
877 'total' => $dueAmount,
878 'status' => Status::TRANSACTION_SUCCEEDED,
879 'payment_method' => sanitize_text_field($request->payment_method),
880 'vendor_charge_id' => sanitize_text_field($request->vendor_charge_id),
881 'payment_mode' => sanitize_text_field($locked->mode),
882 'payment_method_type' => sanitize_text_field($request->payment_method),
883 'order_type' => sanitize_text_field($locked->type),
884 'currency' => sanitize_text_field($locked->currency),
885 ];
886
887 if ($transaction) {
888 // Don't include transaction_type in the update — the existing value is always 'charge'
889 // and overwriting it with the request value would break syncSubscriptionStates bill_count.
890 $transaction->update($newTransactionData);
891 } else {
892 $transaction = OrderTransaction::query()->create(
893 array_merge($newTransactionData, [
894 'order_id' => $locked->id,
895 'transaction_type' => Status::TRANSACTION_TYPE_CHARGE,
896 ])
897 );
898 }
899
900 // Persist the settled balance while the row lock is held so the next request
901 // to acquire it computes due = 0. payment_status is deliberately left alone:
902 // syncOrderStatuses() owns the atomic pending → paid claim that dispatches
903 // OrderPaid exactly once, and it runs after commit so third-party hook
904 // callbacks (emails, integrations, subscription activation) never execute
905 // while the order row is locked.
906 $locked->total_paid = (int) OrderTransaction::query()
907 ->where('order_id', $locked->id)
908 ->whereIn('status', Status::getTransactionSuccessStatuses())
909 ->sum('total');
910
911 $note = sanitize_text_field($request->get('mark_paid_note', ''));
912 if ($note) {
913 $locked->note = $note;
914 }
915 $locked->save();
916
917 $db->commit();
918 } catch (\Throwable $e) {
919 $db->rollBack();
920 throw $e;
921 }
922
923 (new StatusHelper($locked))->syncOrderStatuses($transaction);
924
925 return $this->response->sendSuccess([
926 'message' => __('Order has been marked as paid', 'fluent-cart')
927 ]);
928 }
929
930 public function handleBulkActions(Request $request)
931 {
932
933 $action = sanitize_text_field($request->get('action', ''));
934 $orderIds = $request->get('order_ids', []);
935
936 if ($action == 'delete_test_orders') {
937 return $this->handleDeleteTestOrdersBulkAction($request);
938 }
939
940 $orderIds = array_map(function ($id) {
941 return (int)$id;
942 }, $orderIds);
943
944 $orderIds = array_filter($orderIds);
945
946 if (!$orderIds) {
947 return $this->sendError([
948 'message' => __('Orders selection is required', 'fluent-cart')
949 ]);
950 }
951
952 if ($action == 'delete_orders') {
953
954 $isDeleted = OrderResource::bulkDeleteByOrderIds($orderIds);
955
956 if (is_wp_error($isDeleted)) {
957 return $isDeleted;
958 }
959 return $this->response->sendSuccess($isDeleted);
960
961 // $DB = App::db();
962 // $DB->beginTransaction();
963
964 // try {
965
966 // OrderResource::bulkDeleteByOrderIds($orderIds);
967 // OrderTransaction::bulkDeleteByOrderIds($orderIds);
968 // OrderMetaResource::bulkDeleteByOrderIds($orderIds);
969 // OrderItemResource::bulkDeleteByOrderIds($orderIds);
970
971 // $DB->commit();
972
973 // return [
974 // 'message' => __('Selected orders and their associated resources have been deleted permanently', 'fluent-cart')
975 // ];
976 // } catch (\Exception $e) {
977 // $DB->rollBack();
978 // return static::makeErrorResponse([
979 // ['code' => 400, 'message' => __('Failed to delete orders and their associated resources', 'fluent-cart')]
980 // ]);
981 // }
982
983
984 }
985
986 // The capture_payments branch was removed: it called
987 // $order->capturePayments(), a method that has never existed anywhere
988 // in the codebase, so the action fataled on the first order (audit
989 // item #43). No UI sends it — the orders bulk bar submits
990 // delete_test_orders only. Bulk payment capture, if wanted, is a
991 // gateway feature to design (authorize/capture per gateway), not a
992 // branch to resurrect as-is.
993
994 return $this->sendError([
995 'message' => __('Selected action is invalid', 'fluent-cart')
996 ]);
997
998 }
999
1000 protected function handleDeleteTestOrdersBulkAction(Request $request)
1001 {
1002 $batchSize = max(1, (int)apply_filters('fluent_cart/order/delete_test_orders_batch_size', 50));
1003 $lastOrderId = max(0, (int)$request->get('last_order_id', 0));
1004 $totalCount = (int)$this->getTestOrdersQuery()->count();
1005
1006 $testOrders = $this->getTestOrdersQuery()
1007 ->select('id')
1008 ->orderBy('id')
1009 ->when($lastOrderId > 0, function ($query) use ($lastOrderId) {
1010 return $query->where('id', '>', $lastOrderId);
1011 })
1012 ->limit($batchSize)
1013 ->get();
1014
1015 $batchOrderIds = $testOrders->pluck('id')->map(function ($id) {
1016 return (int)$id;
1017 })->toArray();
1018
1019 if (!$batchOrderIds) {
1020 return $this->sendSuccess([
1021 'message' => $lastOrderId
1022 ? __('Test order deletion completed.', 'fluent-cart')
1023 : __('No test orders found to delete', 'fluent-cart'),
1024 'total_count' => $totalCount,
1025 'batch_size' => $batchSize,
1026 'batch_count' => 0,
1027 'deleted_count' => 0,
1028 'failed_count' => 0,
1029 'deleted_order_ids' => [],
1030 'failed_order_ids' => [],
1031 'last_attempted_order_id' => $lastOrderId,
1032 'has_more' => false
1033 ]);
1034 }
1035
1036 $isDeleted = OrderResource::bulkDeleteByOrderIds($batchOrderIds);
1037
1038 if (is_wp_error($isDeleted)) {
1039 $deletedOrderIds = [];
1040 $failedOrderIds = $batchOrderIds;
1041 } else {
1042 $deletedOrderIds = array_values(array_unique(array_map('intval', Arr::get($isDeleted, 'data.deleted_order_ids', []))));
1043 $failedOrderIds = array_values(array_unique(array_map('intval', Arr::get($isDeleted, 'data.failed_order_ids', []))));
1044 }
1045
1046 $deletedCount = count($deletedOrderIds);
1047 $failedCount = count($failedOrderIds);
1048 $lastAttemptedOrderId = (int)end($batchOrderIds);
1049 $hasMore = $this->getTestOrdersQuery()
1050 ->where('id', '>', $lastAttemptedOrderId)
1051 ->exists();
1052
1053 return $this->sendSuccess([
1054 'message' => $hasMore
1055 ? __('Deleting test orders...', 'fluent-cart')
1056 : __('Test order deletion completed.', 'fluent-cart'),
1057 'total_count' => $totalCount,
1058 'batch_size' => $batchSize,
1059 'batch_count' => count($batchOrderIds),
1060 'deleted_count' => $deletedCount,
1061 'failed_count' => $failedCount,
1062 'deleted_order_ids' => $deletedOrderIds,
1063 'failed_order_ids' => $failedOrderIds,
1064 'last_attempted_order_id' => $lastAttemptedOrderId,
1065 'has_more' => $hasMore
1066 ]);
1067 }
1068
1069 public function updateTransactionStatus(Request $request, $order, OrderTransaction $transaction)
1070 {
1071
1072 $order = Order::query()->find($order);
1073 $newStatus = sanitize_text_field($request->get('status', ''));
1074
1075 $validStatuses = Status::getEditableTransactionStatuses();
1076 if (!isset($validStatuses[$newStatus])) {
1077 return $this->sendError([
1078 'message' => __('Provided transaction status is not valid', 'fluent-cart')
1079 ]);
1080 }
1081
1082 if ($transaction->status == $newStatus) {
1083 return $this->sendError([
1084 'reload' => true,
1085 'message' => __('Transaction already has the same status', 'fluent-cart')
1086 ]);
1087 }
1088
1089 if ($transaction->order_id != $order->id) {
1090 return $this->sendError([
1091 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart')
1092 ]);
1093 }
1094
1095 // Money already counted into the order cannot be changed from a dropdown; returning
1096 // it is a refund, which records a refund transaction through the refund action (FC-SEC-09).
1097 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
1098 return $this->sendError([
1099 'message' => __('A succeeded transaction cannot be changed here. Use the refund action to return the payment.', 'fluent-cart')
1100 ], 422);
1101 }
1102
1103 $transaction->updateStatus($newStatus);
1104
1105 if ($newStatus === Status::TRANSACTION_SUCCEEDED) {
1106 // 'succeeded' is a transaction word, not an order payment status: derive the
1107 // order's paid state and total_paid from its transactions, as mark-as-paid does.
1108 (new StatusHelper($order))->syncOrderStatuses($transaction);
1109 } else {
1110 $order->updatePaymentStatus($newStatus);
1111 }
1112
1113 return [
1114 'transaction' => $transaction,
1115 'message' => __('Payment status has been successfully updated', 'fluent-cart')
1116 ];
1117 }
1118
1119 public function syncPendingTransaction(Request $request, $order, OrderTransaction $transaction)
1120 {
1121 $order = Order::query()->find($order);
1122
1123 if (!$order || $transaction->order_id != $order->id) {
1124 return $this->sendError([
1125 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart')
1126 ]);
1127 }
1128
1129 $result = $transaction->syncPendingTransaction();
1130
1131 if (is_wp_error($result)) {
1132 return $this->sendError([
1133 'message' => $result->get_error_message()
1134 ]);
1135 }
1136
1137 return $this->sendSuccess([
1138 'message' => __('Transaction has been synced from the payment gateway successfully!', 'fluent-cart'),
1139 'transaction' => $result
1140 ]);
1141 }
1142
1143 public function getStats($orderUuid): \WP_REST_Response
1144 {
1145 $order = OrderResource::find($orderUuid);
1146 return $this->sendSuccess([
1147 'widgets' => apply_filters('fluent_cart/widgets/single_order', [], $order)
1148 ]);
1149 }
1150
1151 public function getShippingMethods(Request $request): \WP_REST_Response
1152 {
1153 $countryCode = $request->get('country_code');
1154 $state = $request->get('state') ?? '';
1155 $orderItems = $this->prepareOrderItemsWithVariations($request->get('order_items'));
1156
1157 $enabledMethods = $this->getEnabledShippingMethodsWithCharges($orderItems);
1158
1159 if (empty($countryCode)) {
1160 return $this->sendSuccess([
1161 'shipping_methods' => [],
1162 'other_shipping_methods' => $enabledMethods,
1163 ]);
1164 }
1165
1166 $applicableMethods = ShippingMethod::getApplicableForCountry($countryCode, $state);
1167
1168 $applicableIds = $applicableMethods->pluck('id')->toArray();
1169
1170 return $this->sendSuccess([
1171 'shipping_methods' => $enabledMethods->whereIn('id', $applicableIds)->values(),
1172 'other_shipping_methods' => $enabledMethods->whereNotIn('id', $applicableIds)->values(),
1173 ]);
1174 }
1175
1176 protected function getEnabledShippingMethodsWithCharges(array $orderItems)
1177 {
1178 return ShippingMethod::query()
1179 ->where('is_enabled', '1')
1180 ->get()
1181 ->each(function ($method) use ($orderItems) {
1182 $method->shipping_charge = CartHelper::calculateShippingMethodCharge($method, $orderItems);
1183 });
1184 }
1185
1186 public function updateShipping(Request $request)
1187 {
1188 $orderItems = $request->get('order_items');
1189 $shippingMethodId = $request->get('shipping_id');
1190
1191 $orderItems = $this->prepareOrderItemsWithVariations($orderItems);
1192
1193
1194 $method = ShippingMethod::query()->find($shippingMethodId);
1195
1196 $totalShippingCharge = CartHelper::calculateShippingMethodCharge($method, $orderItems);
1197
1198 return $this->sendSuccess([
1199 'message' => __('Shipping updated', 'fluent-cart'),
1200 'shipping_charge' => $totalShippingCharge,
1201 'order_items' => $orderItems
1202 ]);
1203
1204 }
1205
1206 /**
1207 * Calculate tax for an admin order given an address and item list.
1208 * No DB writes — pure calculation helper.
1209 *
1210 * Accepts: { country, state, city, postcode, items: [{post_id, object_id, subtotal, discount_total}] }
1211 * Returns: { tax_total, shipping_tax, tax_lines, tax_behavior, tax_country }
1212 */
1213 public function calculateTax(Request $request)
1214 {
1215 $address = [
1216 'country' => sanitize_text_field($request->get('country', '')),
1217 'state' => sanitize_text_field($request->get('state', '')),
1218 'city' => sanitize_text_field($request->get('city', '')),
1219 'postcode' => sanitize_text_field($request->get('postcode', '')),
1220 ];
1221
1222 $rawItems = $request->get('items', []);
1223 if (!is_array($rawItems)) {
1224 $rawItems = [];
1225 } elseif (count($rawItems) > 100) {
1226 $rawItems = array_slice($rawItems, 0, 100);
1227 }
1228
1229 $items = [];
1230 foreach ($rawItems as $item) {
1231 $items[] = [
1232 'post_id' => (int) Arr::get($item, 'post_id', 0),
1233 'object_id' => (int) Arr::get($item, 'object_id', 0),
1234 'subtotal' => (int) Arr::get($item, 'subtotal', 0),
1235 'discount_total' => (int) Arr::get($item, 'discount_total', 0),
1236 'shipping_charge' => (int) Arr::get($item, 'shipping_charge', 0),
1237 'quantity' => max(1, (int) Arr::get($item, 'quantity', 1)),
1238 ];
1239 }
1240
1241 $result = \FluentCart\App\Services\Tax\AdminOrderTaxService::calculate($items, $address);
1242
1243 if ($result === null) {
1244 return $this->sendSuccess([
1245 'tax_total' => 0,
1246 'shipping_tax' => 0,
1247 'tax_lines' => [],
1248 'tax_behavior' => 0,
1249 'tax_country' => '',
1250 ]);
1251 }
1252
1253 $taxLines = Arr::get($result, 'tax_lines', []);
1254 $strippedTaxLines = array_values(array_map(function ($line) {
1255 return [
1256 'label' => Arr::get($line, 'label', ''),
1257 'rate_percent' => Arr::get($line, 'rate_percent', 0),
1258 'tax_amount' => (int) Arr::get($line, 'tax_amount', 0),
1259 'inclusive' => (bool) Arr::get($line, 'inclusive', false),
1260 ];
1261 }, $taxLines));
1262
1263 return $this->sendSuccess([
1264 'tax_total' => (int) Arr::get($result, 'tax_total', 0),
1265 'shipping_tax' => (int) Arr::get($result, 'shipping_tax', 0),
1266 'tax_behavior' => (int) Arr::get($result, 'tax_behavior', 0),
1267 'tax_country' => Arr::get($result, 'tax_country', ''),
1268 'tax_lines' => $strippedTaxLines,
1269 ]);
1270 }
1271
1272 protected function prepareOrderItemsWithVariations($orderItems)
1273 {
1274 $itemCollection = (new Collection($orderItems))->keyBy('id');
1275 $ids = $itemCollection->keys()->toArray();
1276 $variations = ProductVariation::query()->with('shippingClass')->whereIn('id', $ids)->get();
1277 $orderItems = $itemCollection->toArray();
1278
1279 foreach ($variations as &$variation) {
1280 $shippingCharge = CartHelper::calculateShippingCharge($variation, $itemCollection->get($variation->id)['quantity']);
1281 $variation->quantity = Arr::get($orderItems, $variation->id . '.' . 'quantity', 1);
1282 $variation->discount_total = Arr::get($orderItems, $variation->id . '.' . 'discount_total', 0);
1283 $variation->shipping_charge = $shippingCharge;
1284 $variation->unit_price = $variation->item_price;
1285
1286 }
1287
1288 $orderItems = $variations->mapWithKeys(function ($item) {
1289 return [
1290 $item->id => [
1291 'id' => $item->id,
1292 'quantity' => $item->quantity,
1293 'shipping_charge' => $item->shipping_charge,
1294 'unit_price' => $item->unit_price,
1295 'other_info' => $item->other_info,
1296 'discount_total' => $item->discount_total,
1297 'fulfillment_type' => $item->fulfillment_type,
1298 ]
1299 ];
1300 });
1301 $orderItems = $orderItems->toArray();
1302
1303 return $orderItems;
1304 }
1305
1306 public function acceptDispute(Request $request, $order, $transaction)
1307 {
1308 $order = Order::query()->find($order);
1309 $transaction = OrderTransaction::query()->find($transaction);
1310 $response = $transaction->acceptDispute([
1311 'dispute_note' => $request->getSafe('dispute_note', 'sanitize_text_field'),
1312 ]);
1313
1314 if (is_wp_error($response)) {
1315 return $this->sendError([
1316 'message' => $response->get_error_message()
1317 ]);
1318 }
1319
1320 return $this->sendSuccess([
1321 'message' => __('Dispute accepted!', 'fluent-cart')
1322 ]);
1323 }
1324
1325 public function syncOrderStatuses(Request $request, Order $order)
1326 {
1327 $latestTransaction = OrderTransaction::query()
1328 ->where('order_id', $order->id)
1329 ->orderBy('id', 'desc')
1330 ->first();
1331
1332 if (!$latestTransaction) {
1333 return $this->sendError([
1334 'message' => __('No transaction found for this order', 'fluent-cart')
1335 ], 404);
1336 }
1337
1338 (new StatusHelper($order))->syncOrderStatuses($latestTransaction);
1339
1340 // Reload order to get updated data
1341 $order = Order::query()->find($order->id);
1342
1343 return $this->sendSuccess([
1344 'message' => __('Order statuses synced successfully', 'fluent-cart'),
1345 'order' => $order,
1346 'payment_status' => $order->payment_status,
1347 'status' => $order->status,
1348 ]);
1349 }
1350
1351 }
1352