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