PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.6 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 All 49 releases
← All changes | app/Http/Controllers/OrderController.php +375 -255 1.3.21 → 1.6.5 View file →
@@ -5,14 +5,11 @@
5 5
6 6 use FluentCart\Api\Resource\CustomerResource;
7 7 use FluentCart\Api\Resource\OrderResource;
8 8 use FluentCart\Api\StoreSettings;
9 -use FluentCart\App\Events\Order\OrderBulkAction;
10 9 use FluentCart\App\Events\Order\OrderCreated;
11 10 use FluentCart\App\Events\Order\OrderDeleting;
12 11 use FluentCart\App\Events\Order\OrderDeleted;
13 -use FluentCart\App\Events\Order\OrderPaid;
14 -use FluentCart\App\Events\Order\OrderStatusUpdated;
15 12 use FluentCart\App\Events\Order\RenewalOrderDeleted;
16 13 use FluentCart\App\Helpers\CartHelper;
17 14 use FluentCart\App\Helpers\Helper;
18 15 use FluentCart\App\Helpers\OrderItemHelper;
@@ -41,9 +38,8 @@
41 38 use FluentCart\App\Models\SubscriptionMeta;
42 39 use FluentCart\App\Services\Filter\OrderFilter;
43 40 use FluentCart\App\Services\Payments\PaymentHelper;
44 41 use FluentCart\App\Services\Reminders\ReminderService;
45 -use FluentCart\App\Services\DateTime\DateTime;
46 42 use FluentCart\App\Services\Payments\Refund;
47 43 use FluentCart\App\Services\URL;
48 44 use FluentCart\Framework\Http\Request\Request;
49 45 use FluentCart\Framework\Support\Arr;
@@ -78,14 +74,27 @@
78 74 public function store(OrderRequest $request)
79 75 {
80 76 $data = $request->getSafe($request->sanitize());
81 77 $type = 'payment';
82 - $hasSubscription = static::hasSubscription(Arr::get($data, 'order_items', []));
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);
83 92 if ($hasSubscription) {
84 93 $type = 'subscription';
85 94 // right now we don't support subscription with manual order
86 - $isSubscriptionAllowedInManualOrder = apply_filters('fluent_cart/order/is_subscription_allowed_in_manual_order', false, [
87 - 'order_items' => Arr::get($data, 'order_items', [])
95 + $isSubscriptionAllowedInManualOrder = apply_filters('fluent_cart/order/is_subscription_allowed_in_manual_order', true, [
96 + 'order_items' => $orderItems
88 97 ]);
89 98
90 99 if (!$isSubscriptionAllowedInManualOrder) {
91 100 return $this->sendError([
@@ -113,20 +122,86 @@
113 122 'uuid' => $order->uuid
114 123 ]);
115 124 }
116 125
117 -
118 126 public static function hasSubscription($orderItems): bool
119 127 {
120 - // check order items for subscription, payment_type == subscription
121 128 foreach ($orderItems as $item) {
122 129 if (Arr::get($item, 'payment_type') == 'subscription' || Arr::get($item, 'other_info.payment_type') == 'subscription') {
123 130 return true;
124 131 }
125 132 }
133 +
126 134 return false;
127 135 }
128 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 +
129 204 public function updateOrder(OrderRequest $request, $order_id)
130 205 {
131 206 $order = Order::query()->find($order_id);
132 207
@@ -203,13 +278,29 @@
203 278
204 279 if (is_wp_error($data)) {
205 280 return $this->sendError($data->get_error_message());
206 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 +
207 299 return $this->sendSuccess([
208 - 'message' => 'Address updated successfully'
300 + 'message' => __('Address updated successfully', 'fluent-cart'),
301 + 'order' => $freshOrder,
209 302 ]);
210 -
211 -
212 303 }
213 304
214 305 public function generateMissingLicenses(Request $request, Order $order)
215 306 {
@@ -234,8 +325,14 @@
234 325
235 326 }
236 327
237 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 + *
238 335 * @throws ValidationException
239 336 */
240 337 public function refundOrder(Request $request, $orderId)
241 338 {
@@ -246,11 +343,16 @@
246 343 'message' => __('Order can not be refunded.', 'fluent-cart')
247 344 ], 400);
248 345 }
249 346
250 - $refundInfo = $request->get('refund_info', []);
347 + $refundInfo = (array)$request->get('refund_info', []);
251 348
252 - $this->validate($refundInfo, [
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, [
253 355 'transaction_id' => 'required',
254 356 'amount' => 'required',
255 357 ], [
256 358 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'),
@@ -256,11 +358,15 @@
256 358 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'),
257 359 'amount.required' => __('Refund amount is required', 'fluent-cart'),
258 360 ]);
259 361
260 - $transaction = OrderTransaction::query()->findOrFail($refundInfo['transaction_id']);
261 - $refundAmount = Helper::toCent($refundInfo['amount']);
362 + if ($validator->validate()->fails()) {
363 + return $this->sendError($validator->errors(), 422);
364 + }
262 365
366 + $transaction = OrderTransaction::query()->where('order_id', $orderId)->findOrFail($refundInfo['transaction_id']);
367 + $refundAmount = Helper::roundCent($refundInfo['amount']);
368 +
263 369 // refund on our end
264 370 $result = (new Refund())->processRefund($transaction, $refundAmount, $refundInfo);
265 371
266 372 if (is_wp_error($result)) {
@@ -313,9 +419,10 @@
313 419 $cancelSubscription = Arr::get($refundInfo, 'cancelSubscription') == 'true';
314 420
315 421 if ($cancelSubscription && $transaction->subscription_id && $transaction->subscription) {
316 422 $vendorSubscriptionCancelled = $transaction->subscription->cancelRemoteSubscription([
317 - 'reason' => 'refunded'
423 + 'reason' => 'refunded',
424 + 'effective_from' => 'immediately'
318 425 ]);
319 426 if (is_wp_error($vendorSubscriptionCancelled)) {
320 427 $responseData['subscription_cancel']['status'] = 'failed';
321 428 $responseData['subscription_cancel']['message'] = $vendorSubscriptionCancelled->get_error_message();
@@ -517,9 +624,9 @@
517 624 // Must run before deleteOrderRelatedData() which removes stock_movement meta and order items.
518 625 (new OrderDeleting($order, $connectedOrderIds, $isTestMode, $order->type))->dispatch();
519 626
520 627 // Pre-load relations before cleanup so the delete events have address data
521 - $order->load('customer', 'shipping_address', 'billing_address');
628 + $order->load(['customer', 'shipping_address', 'billing_address']);
522 629
523 630 $this->deleteOrderRelatedData($connectedOrderIds, $isTestMode);
524 631 $DB->commit();
525 632 } catch (\Exception $e) {
@@ -613,18 +720,52 @@
613 720
614 721 if (empty($data['order']['receipt_url'])) {
615 722 $data['order']['receipt_url'] = $url;
616 723 }
617 - $meta = OrderMeta::query()->where('order_id', $orderId)
618 - ->where('meta_key', 'vat_tax_id')
619 - ->first();
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']);
620 729
621 - if ($meta) {
622 - $data['tax_id'] = $meta->meta_value;
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 + );
623 751 }
624 752
625 - $data['can_send_payment_reminder'] = (new ReminderService())->canSendPaymentReminder($data['order']);
753 + $data = $this->getDetails($orderId);
626 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 +
627 768 return $data;
628 769 }
629 770
630 771 public function createCustom(Request $request, OrderItemHelper $orderItemHelper, Order $order)
@@ -629,13 +770,18 @@
629 770
630 771 public function createCustom(Request $request, OrderItemHelper $orderItemHelper, Order $order)
631 772 {
632 773 try {
633 - return $orderItemHelper->processCustom(
774 + $orderItem = $orderItemHelper->processCustom(
634 775 $request->product,
635 776 $order->id
636 777 );
637 778
779 + return $this->sendSuccess([
780 + 'message' => __('Custom item has been added to the order!', 'fluent-cart'),
781 + 'order_item' => $orderItem
782 + ]);
783 +
638 784 } catch (\Exception $e) {
639 785 return $this->sendError([
640 786 'message' => $e->getMessage()
641 787 ], 423);
@@ -680,88 +826,103 @@
680 826
681 827
682 828 public function markAsPaid(Request $request, Order $order)
683 829 {
684 - $dueAmount = intval($order->total_amount - $order->total_paid);
830 + $db = Order::query()->getConnection();
831 + $db->beginTransaction();
685 832
686 - if ($dueAmount <= 0) {
687 - return $this->sendError([
688 - 'message' => __('Order has already been paid', 'fluent-cart')
689 - ], 423);
690 - }
833 + try {
834 + $locked = Order::query()
835 + ->where('id', $order->id)
836 + ->lockForUpdate()
837 + ->first();
691 838
692 - if (Arr::get($order, 'status') === 'canceled') {
693 - return $this->sendError([
694 - 'message' => __('Unable to mark paid for canceled order', 'fluent-cart')
695 - ], 423);
696 - }
839 + if (!$locked) {
840 + $db->rollBack();
841 + return $this->sendError([
842 + 'message' => __('Order not found', 'fluent-cart')
843 + ], 404);
844 + }
697 845
698 - $transaction = $order->transactions->where('status', Status::TRANSACTION_PENDING)
699 - ->where('payment_method', 'offline_payment')
700 - ->first();
846 + $dueAmount = intval($locked->total_amount - $locked->total_paid);
701 847
702 - $newTransactionData = [
703 - 'total' => $dueAmount,
704 - 'status' => Status::TRANSACTION_SUCCEEDED,
705 - 'payment_method' => sanitize_text_field($request->payment_method),
706 - 'vendor_charge_id' => sanitize_text_field($request->vendor_charge_id),
707 - 'payment_mode' => sanitize_text_field($order->mode),
708 - 'payment_method_type' => sanitize_text_field($request->payment_method),
709 - 'order_type' => sanitize_text_field($order->type),
710 - 'transaction_type' => sanitize_text_field($request->transaction_type),
711 - 'currency' => sanitize_text_field($order->currency),
712 - ];
848 + if ($dueAmount <= 0) {
849 + $db->rollBack();
850 + return $this->sendError([
851 + 'message' => __('Order has already been paid', 'fluent-cart')
852 + ], 423);
853 + }
713 854
714 - if ($transaction) {
715 - $transaction->update($newTransactionData);
716 - } else {
717 - $transaction = OrderTransaction::query()->create(
718 - array_merge($newTransactionData, [
719 - 'order_id' => $order->id
720 - ])
721 - );
722 - }
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 + }
723 861
724 - $order->note = sanitize_text_field($request->get('mark_paid_note', ''));
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();
725 875
726 - $oldStatus = $order->status;
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 + ];
727 886
728 - if ($order->payment_status !== 'partially_refunded') {
729 - $order->payment_status = Status::PAYMENT_PAID;
730 - }
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 + }
731 899
732 - $order->status = Status::ORDER_PROCESSING;
733 - $order->total_paid = $order->total_amount;
734 - $order->save();
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');
735 910
736 - $actionActivity = [
737 - 'title' => __('Order status updated', 'fluent-cart'),
738 - 'content' => sprintf(
739 - /* translators: 1: old status, 2: new status */
740 - __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), $oldStatus, $order->status)
741 - ];
911 + $note = sanitize_text_field($request->get('mark_paid_note', ''));
912 + if ($note) {
913 + $locked->note = $note;
914 + }
915 + $locked->save();
742 916
743 - // dispatching events related to order status update and payment paid
744 - (new OrderPaid($order, $order->customer, $transaction))->dispatch();
917 + $db->commit();
918 + } catch (\Throwable $e) {
919 + $db->rollBack();
920 + throw $e;
921 + }
745 922
746 - (new OrderStatusUpdated($order, $oldStatus, $order->status, true, $actionActivity, 'order_status'))->dispatch();
923 + (new StatusHelper($locked))->syncOrderStatuses($transaction);
747 924
748 - // if digital
749 - if ($order->fulfillment_type == 'digital' && $order->status === Status::ORDER_PROCESSING) {
750 - $order->status = Status::ORDER_COMPLETED;
751 - $order->completed_at = DateTime::gmtNow();
752 - $order->save();
753 -
754 - $actionActivity = [
755 - 'title' => __('Order status updated', 'fluent-cart'),
756 - 'content' => sprintf(
757 - /* translators: 1: old status, 2: new status */
758 - __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), Status::ORDER_PROCESSING, $order->status)
759 - ];
760 -
761 - (new OrderStatusUpdated($order, Status::ORDER_PROCESSING, $order->status, true, $actionActivity, 'order_status'))->dispatch();
762 - }
763 -
764 925 return $this->response->sendSuccess([
765 926 'message' => __('Order has been marked as paid', 'fluent-cart')
766 927 ]);
767 928 }
@@ -787,11 +948,8 @@
787 948 'message' => __('Orders selection is required', 'fluent-cart')
788 949 ]);
789 950 }
790 951
791 - $orders = Order::query()->whereIn('id', $orderIds)->get();
792 -
793 -
794 952 if ($action == 'delete_orders') {
795 953
796 954 $isDeleted = OrderResource::bulkDeleteByOrderIds($orderIds);
797 955
@@ -823,168 +981,17 @@
823 981 // }
824 982
825 983
826 984 }
827 - if ($action == 'change_shipping_status') {
828 - $newStatus = sanitize_text_field($request->get('new_status', ''));
829 - if (!$newStatus) {
830 - return $this->sendError([
831 - 'message' => __('Please select status', 'fluent-cart')
832 - ]);
833 - }
834 985
835 - $validStatuses = Helper::getShippingStatuses();
836 - if (!isset($validStatuses[$newStatus])) {
837 - return $this->sendError([
838 - 'message' => __('Provided shipping status is not valid', 'fluent-cart')
839 - ]);
840 - }
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.
841 993
842 - // foreach ($orders as $order) {
843 - // $order->updateShippingStatus($newStatus);
844 - // }
845 -
846 - return [
847 - 'message' => __('Shipping Status has been changed for the selected orders', 'fluent-cart')
848 - ];
849 -
850 - }
851 - if ($action == 'change_order_status') {
852 -
853 - $newStatus = sanitize_text_field($request->get('new_status', ''));
854 - if (!$newStatus) {
855 - return $this->sendError([
856 - 'message' => __('Please select status', 'fluent-cart')
857 - ]);
858 - }
859 -
860 - $validStatuses = Status::getEditableOrderStatuses();
861 - if (!isset($validStatuses[$newStatus])) {
862 - return $this->sendError([
863 - 'message' => __('Provided order status is not valid', 'fluent-cart')
864 - ]);
865 - }
866 -
867 - $failedOrderIds = [];
868 - $updatedOrderIds = [];
869 -
870 - foreach ($orders as $order) {
871 - // $order->updateStatus('status', $newStatus);
872 - $isUpdated = OrderResource::updateStatuses([
873 - 'order' => $order,
874 - 'action' => 'change_order_status',
875 - 'statuses.order_status' => $newStatus,
876 - 'manage_stock' => sanitize_text_field($request->get('manage_stock')),
877 - ]);
878 -
879 - if (is_wp_error($isUpdated)) {
880 - $failedOrderIds[] = $order->id;
881 - } else {
882 - $updatedOrderIds[] = $order->id;
883 - }
884 - }
885 -
886 - if (count($failedOrderIds) > 0) {
887 - $failedOrderIds = implode(' , ', $failedOrderIds);
888 - return count($updatedOrderIds) > 0
889 - ? $this->sendSuccess([
890 - 'message' => sprintf(
891 - /* translators: %s is the order ids */
892 - __("The order ID - %s cannot be updated because they are either already cancelled or have the same status. And remaining order status has been successfully changed", 'fluent-cart'), $failedOrderIds)
893 - ])
894 - :
895 - $this->sendError([
896 - 'message' => sprintf(
897 - /* translators: %s is the order ids */
898 - __("The order ID - %s cannot be updated because they are either already cancelled or have the same status.", 'fluent-cart'), $failedOrderIds)
899 - ], 423);
900 - }
901 -
902 - if (count($updatedOrderIds) > 0 && count($failedOrderIds) < 1) {
903 - return $this->sendSuccess([
904 - 'message' => __('Order Status has been changed for the selected orders', 'fluent-cart')
905 - ]);
906 - }
907 - }
908 -
909 - if ($action == 'capture_payments') {
910 - foreach ($orders as $order) {
911 - $order->capturePayments();
912 - }
913 -
914 - return [
915 - 'message' => __('Selected payments has been successfully captured', 'fluent-cart')
916 - ];
917 - }
918 -
919 - if ($action == 'change_payment_status') {
920 - $newStatus = sanitize_text_field($request->get('new_status', ''));
921 - if (!$newStatus) {
922 - return $this->sendError([
923 - 'message' => __('Please select status', 'fluent-cart')
924 - ]);
925 - }
926 -
927 - $validStatuses = Status::getEditableTransactionStatuses();
928 - if (!isset($validStatuses[$newStatus])) {
929 - return $this->sendError([
930 - 'message' => __('Provided payment status is not valid', 'fluent-cart')
931 - ]);
932 - }
933 -
934 - $failedOrderIds = [];
935 - $updatedOrderIds = [];
936 - $count = 0;
937 - $customerIds = [];
938 -
939 - foreach ($orders as $order) {
940 - $transaction = $order->latest_transaction;
941 - $isUpdated = OrderResource::updatePaymentStatus([
942 - 'order' => $order,
943 - 'status' => $newStatus,
944 - 'transaction' => $transaction,
945 - ]);
946 -
947 - if (is_wp_error($isUpdated)) {
948 - $failedOrderIds[] = $order->id;
949 - } else {
950 - $updatedOrderIds[] = $order->id;
951 - $count++;
952 - $customerIds[] = $order->customer_id;
953 - }
954 - }
955 -
956 - if ($count > 0 && count($customerIds) > 0) {
957 - (new OrderBulkAction($customerIds))->dispatch();
958 - }
959 -
960 - if (count($failedOrderIds) > 0) {
961 - $failedOrderIds = implode(' , ', $failedOrderIds);
962 - return count($updatedOrderIds) > 0
963 - ? $this->sendSuccess([
964 - 'message' => sprintf(
965 - /* translators: %s is the order ids */
966 - __("The order ID - %s cannot be updated at the moment because the transaction either already has the same status or does not match the provided order. The remaining order statuses have been updated successfully.", 'fluent-cart'), $failedOrderIds)
967 - ])
968 - :
969 - $this->sendError([
970 - 'message' => sprintf(
971 - /* translators: %s is the order ids */
972 - __("The order ID - %s cannot be updated at the moment because its payment status is either the same as before or has already been refunded.", 'fluent-cart'), $failedOrderIds)
973 - ], 423);
974 - }
975 -
976 - if (count($updatedOrderIds) > 0 && count($failedOrderIds) < 1) {
977 - return $this->sendSuccess([
978 - 'message' => sprintf(
979 - /* translators: %s is the payment status */
980 - __("Selected orders payment status has been marked as %s", 'fluent-cart'),
981 - $newStatus
982 - )
983 - ]);
984 - }
985 - }
986 -
987 994 return $this->sendError([
988 995 'message' => __('Selected action is invalid', 'fluent-cart')
989 996 ]);
990 997
@@ -1062,9 +1069,17 @@
1062 1069 public function updateTransactionStatus(Request $request, $order, OrderTransaction $transaction)
1063 1070 {
1064 1071
1065 1072 $order = Order::query()->find($order);
1066 - $newStatus = $request->get('status');
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 +
1067 1082 if ($transaction->status == $newStatus) {
1068 1083 return $this->sendError([
1069 1084 'reload' => true,
1070 1085 'message' => __('Transaction already has the same status', 'fluent-cart')
@@ -1076,11 +1091,26 @@
1076 1091 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart')
1077 1092 ]);
1078 1093 }
1079 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 +
1080 1103 $transaction->updateStatus($newStatus);
1081 - $order->updatePaymentStatus($newStatus);
1082 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 +
1083 1113 return [
1084 1114 'transaction' => $transaction,
1085 1115 'message' => __('Payment status has been successfully updated', 'fluent-cart')
1086 1116 ];
@@ -1085,8 +1115,32 @@
1085 1115 'message' => __('Payment status has been successfully updated', 'fluent-cart')
1086 1116 ];
1087 1117 }
1088 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 +
1089 1143 public function getStats($orderUuid): \WP_REST_Response
1090 1144 {
1091 1145 $order = OrderResource::find($orderUuid);
1092 1146 return $this->sendSuccess([
@@ -1146,8 +1200,74 @@
1146 1200 'shipping_charge' => $totalShippingCharge,
1147 1201 'order_items' => $orderItems
1148 1202 ]);
1149 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 + ]);
1150 1270 }
1151 1271
1152 1272 protected function prepareOrderItemsWithVariations($orderItems)
1153 1273 {