PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 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 All 51 releases
← All changes | app/Http/Controllers/OrderController.php +384 -255 1.3.21 → 1.7.1 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
@@ -136,9 +211,12 @@
136 211 ], 400);
137 212 }
138 213
139 214
140 - $requestData = $request->getSafe($request->sanitize());
215 + $requestData = array_intersect_key(
216 + $request->getSafe($request->sanitize()),
217 + $request->all()
218 + );
141 219
142 220 $totalPaid = Arr::get($request->all(), 'total_paid');
143 221 $updatedTotal = Arr::get($requestData, 'total_amount');
144 222
@@ -159,9 +237,9 @@
159 237 // if new shipping total is already adjusted in total amount, then no need to adjust again, right now not adjusted before
160 238 // ToDo: adjust changed shipping total in total amount prior to this
161 239 $shippingTotal = Arr::get($requestData, 'shipping_total', 0);
162 240 $oldShippingTotal = Arr::get($order, 'shipping_total', 0);
163 - if ($shippingTotal != $oldShippingTotal) {
241 + if (array_key_exists('shipping_total', $requestData) && $shippingTotal != $oldShippingTotal) {
164 242 $diff = $shippingTotal - $oldShippingTotal;
165 243 if ($diff < 0) {
166 244 $requestData['total_amount'] = $updatedTotal - abs($diff);
167 245 } else {
@@ -203,13 +281,29 @@
203 281
204 282 if (is_wp_error($data)) {
205 283 return $this->sendError($data->get_error_message());
206 284 }
285 +
286 + // Changing the order address recalculates tax server-side
287 + // (OrderResource::updateOrderAddressId → reapplyTaxAfterUpdate). Return the
288 + // refreshed order with the tax appends so the admin UI can update the tax
289 + // summary, totals and payment status without a full page reload.
290 + $freshOrder = Order::query()->where('id', $order_id)
291 + ->addAppends([
292 + 'business_info',
293 + 'customer_tax_number',
294 + 'is_b2b_order',
295 + 'display_tax_lines',
296 + 'display_shipping_tax_lines',
297 + 'is_reverse_charge_tax_order',
298 + 'tax_summary',
299 + ])
300 + ->first();
301 +
207 302 return $this->sendSuccess([
208 - 'message' => 'Address updated successfully'
303 + 'message' => __('Address updated successfully', 'fluent-cart'),
304 + 'order' => $freshOrder,
209 305 ]);
210 -
211 -
212 306 }
213 307
214 308 public function generateMissingLicenses(Request $request, Order $order)
215 309 {
@@ -234,8 +328,14 @@
234 328
235 329 }
236 330
237 331 /**
332 + * Refund against an order transaction.
333 + *
334 + * `refund_info.amount` is in CENTS, matching every money value in a read response and
335 + * the stored column. So {"amount": 2500} refunds $25.00. roundCent() below only
336 + * normalizes float artifacts; it does not scale. See dev-docs/PRICING-AND-TAX.md §6.
337 + *
238 338 * @throws ValidationException
239 339 */
240 340 public function refundOrder(Request $request, $orderId)
241 341 {
@@ -246,11 +346,16 @@
246 346 'message' => __('Order can not be refunded.', 'fluent-cart')
247 347 ], 400);
248 348 }
249 349
250 - $refundInfo = $request->get('refund_info', []);
350 + $refundInfo = (array)$request->get('refund_info', []);
251 351
252 - $this->validate($refundInfo, [
352 + // $this->validate() reports failures only by exception, and outside a
353 + // REST_REQUEST context the framework swallows that exception (no
354 + // handle_exception listener) — execution would continue and crash on
355 + // $refundInfo['transaction_id'] below. Fail closed: run the validator
356 + // directly and return the per-field 422 payload in every context.
357 + $validator = $this->app->validator->make($refundInfo, [
253 358 'transaction_id' => 'required',
254 359 'amount' => 'required',
255 360 ], [
256 361 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'),
@@ -256,11 +361,15 @@
256 361 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'),
257 362 'amount.required' => __('Refund amount is required', 'fluent-cart'),
258 363 ]);
259 364
260 - $transaction = OrderTransaction::query()->findOrFail($refundInfo['transaction_id']);
261 - $refundAmount = Helper::toCent($refundInfo['amount']);
365 + if ($validator->validate()->fails()) {
366 + return $this->sendError($validator->errors(), 422);
367 + }
262 368
369 + $transaction = OrderTransaction::query()->where('order_id', $orderId)->findOrFail($refundInfo['transaction_id']);
370 + $refundAmount = Helper::roundCent($refundInfo['amount']);
371 +
263 372 // refund on our end
264 373 $result = (new Refund())->processRefund($transaction, $refundAmount, $refundInfo);
265 374
266 375 if (is_wp_error($result)) {
@@ -313,9 +422,10 @@
313 422 $cancelSubscription = Arr::get($refundInfo, 'cancelSubscription') == 'true';
314 423
315 424 if ($cancelSubscription && $transaction->subscription_id && $transaction->subscription) {
316 425 $vendorSubscriptionCancelled = $transaction->subscription->cancelRemoteSubscription([
317 - 'reason' => 'refunded'
426 + 'reason' => 'refunded',
427 + 'effective_from' => 'immediately'
318 428 ]);
319 429 if (is_wp_error($vendorSubscriptionCancelled)) {
320 430 $responseData['subscription_cancel']['status'] = 'failed';
321 431 $responseData['subscription_cancel']['message'] = $vendorSubscriptionCancelled->get_error_message();
@@ -517,9 +627,9 @@
517 627 // Must run before deleteOrderRelatedData() which removes stock_movement meta and order items.
518 628 (new OrderDeleting($order, $connectedOrderIds, $isTestMode, $order->type))->dispatch();
519 629
520 630 // Pre-load relations before cleanup so the delete events have address data
521 - $order->load('customer', 'shipping_address', 'billing_address');
631 + $order->load(['customer', 'shipping_address', 'billing_address']);
522 632
523 633 $this->deleteOrderRelatedData($connectedOrderIds, $isTestMode);
524 634 $DB->commit();
525 635 } catch (\Exception $e) {
@@ -613,18 +723,52 @@
613 723
614 724 if (empty($data['order']['receipt_url'])) {
615 725 $data['order']['receipt_url'] = $url;
616 726 }
617 - $meta = OrderMeta::query()->where('order_id', $orderId)
618 - ->where('meta_key', 'vat_tax_id')
619 - ->first();
727 + $taxNumber = Arr::get($data, 'order.customer_tax_number', '');
728 + if (!empty($taxNumber)) {
729 + $data['tax_id'] = $taxNumber;
730 + }
731 + unset($data['order']['customer_tax_number']);
620 732
621 - if ($meta) {
622 - $data['tax_id'] = $meta->meta_value;
733 + $data['can_send_payment_reminder'] = (new ReminderService())->canSendPaymentReminder($data['order']);
734 +
735 + return $data;
736 + }
737 +
738 + public function getTransactionDetails($orderId, $transactionId)
739 + {
740 + $orderId = (int)$orderId;
741 + $transactionId = (int)$transactionId;
742 +
743 + $belongsToOrder = OrderTransaction::query()
744 + ->where('id', $transactionId)
745 + ->where('order_id', $orderId)
746 + ->exists();
747 +
748 + if (!$belongsToOrder) {
749 + return $this->entityNotFoundError(
750 + __('Transaction not found', 'fluent-cart'),
751 + __('Back to orders', 'fluent-cart'),
752 + '/orders'
753 + );
623 754 }
624 755
625 - $data['can_send_payment_reminder'] = (new ReminderService())->canSendPaymentReminder($data['order']);
756 + $data = $this->getDetails($orderId);
626 757
758 + if (!is_array($data) || empty($data['order'])) {
759 + return $data;
760 + }
761 +
762 + // The path names one transaction, so the sibling rows on the same order
763 + // are not part of this response.
764 + $data['order']['transactions'] = array_values(array_filter(
765 + (array)Arr::get($data, 'order.transactions', []),
766 + function ($transaction) use ($transactionId) {
767 + return (int)Arr::get($transaction, 'id') === $transactionId;
768 + }
769 + ));
770 +
627 771 return $data;
628 772 }
629 773
630 774 public function createCustom(Request $request, OrderItemHelper $orderItemHelper, Order $order)
@@ -629,13 +773,18 @@
629 773
630 774 public function createCustom(Request $request, OrderItemHelper $orderItemHelper, Order $order)
631 775 {
632 776 try {
633 - return $orderItemHelper->processCustom(
777 + $orderItem = $orderItemHelper->processCustom(
634 778 $request->product,
635 779 $order->id
636 780 );
637 781
782 + return $this->sendSuccess([
783 + 'message' => __('Custom item has been added to the order!', 'fluent-cart'),
784 + 'order_item' => $orderItem
785 + ]);
786 +
638 787 } catch (\Exception $e) {
639 788 return $this->sendError([
640 789 'message' => $e->getMessage()
641 790 ], 423);
@@ -680,86 +829,107 @@
680 829
681 830
682 831 public function markAsPaid(Request $request, Order $order)
683 832 {
684 - $dueAmount = intval($order->total_amount - $order->total_paid);
833 + $db = Order::query()->getConnection();
834 + $db->beginTransaction();
685 835
686 - if ($dueAmount <= 0) {
687 - return $this->sendError([
688 - 'message' => __('Order has already been paid', 'fluent-cart')
689 - ], 423);
690 - }
836 + try {
837 + $locked = Order::query()
838 + ->where('id', $order->id)
839 + ->lockForUpdate()
840 + ->first();
691 841
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 - }
842 + if (!$locked) {
843 + $db->rollBack();
844 + return $this->sendError([
845 + 'message' => __('Order not found', 'fluent-cart')
846 + ], 404);
847 + }
697 848
698 - $transaction = $order->transactions->where('status', Status::TRANSACTION_PENDING)
699 - ->where('payment_method', 'offline_payment')
700 - ->first();
849 + $dueAmount = intval($locked->total_amount - $locked->total_paid);
701 850
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 - ];
851 + if ($dueAmount <= 0) {
852 + $db->rollBack();
853 + return $this->sendError([
854 + 'message' => __('Order has already been paid', 'fluent-cart')
855 + ], 423);
856 + }
713 857
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 - }
858 + if ($locked->status === Status::ORDER_CANCELED) {
859 + $db->rollBack();
860 + return $this->sendError([
861 + 'message' => __('Unable to mark paid for canceled order', 'fluent-cart')
862 + ], 423);
863 + }
723 864
724 - $order->note = sanitize_text_field($request->get('mark_paid_note', ''));
865 + // Reuse an existing pending transaction without vendor_charge_id instead of
866 + // creating a new one. Queried fresh (not via the route-bound relation) so it
867 + // reflects the state under the lock.
868 + $transaction = OrderTransaction::query()
869 + ->where('order_id', $locked->id)
870 + ->where('status', Status::TRANSACTION_PENDING)
871 + ->where(function ($query) {
872 + $query->whereNull('vendor_charge_id')
873 + ->orWhere('vendor_charge_id', '');
874 + })
875 + ->orderBy('id', 'asc')
876 + ->lockForUpdate()
877 + ->first();
725 878
726 - $oldStatus = $order->status;
879 + $newTransactionData = [
880 + 'total' => $dueAmount,
881 + 'status' => Status::TRANSACTION_SUCCEEDED,
882 + 'payment_method' => sanitize_text_field($request->payment_method),
883 + 'vendor_charge_id' => sanitize_text_field($request->vendor_charge_id),
884 + 'payment_mode' => sanitize_text_field($locked->mode),
885 + 'payment_method_type' => sanitize_text_field($request->payment_method),
886 + 'order_type' => sanitize_text_field($locked->type),
887 + 'currency' => sanitize_text_field($locked->currency),
888 + ];
727 889
728 - if ($order->payment_status !== 'partially_refunded') {
729 - $order->payment_status = Status::PAYMENT_PAID;
730 - }
890 + if ($transaction) {
891 + // Don't include transaction_type in the update — the existing value is always 'charge'
892 + // and overwriting it with the request value would break syncSubscriptionStates bill_count.
893 + $transaction->update($newTransactionData);
894 + } else {
895 + $transaction = OrderTransaction::query()->create(
896 + array_merge($newTransactionData, [
897 + 'order_id' => $locked->id,
898 + 'transaction_type' => Status::TRANSACTION_TYPE_CHARGE,
899 + ])
900 + );
901 + }
731 902
732 - $order->status = Status::ORDER_PROCESSING;
733 - $order->total_paid = $order->total_amount;
734 - $order->save();
903 + // Persist the settled balance while the row lock is held so the next request
904 + // to acquire it computes due = 0. payment_status is deliberately left alone:
905 + // syncOrderStatuses() owns the atomic pending → paid claim that dispatches
906 + // OrderPaid exactly once, and it runs after commit so third-party hook
907 + // callbacks (emails, integrations, subscription activation) never execute
908 + // while the order row is locked.
909 + $locked->total_paid = (int) OrderTransaction::query()
910 + ->where('order_id', $locked->id)
911 + ->whereIn('status', Status::getTransactionSuccessStatuses())
912 + ->sum('total');
735 913
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 - ];
914 + $locked->save();
742 915
743 - // dispatching events related to order status update and payment paid
744 - (new OrderPaid($order, $order->customer, $transaction))->dispatch();
916 + $db->commit();
917 + } catch (\Throwable $e) {
918 + $db->rollBack();
919 + throw $e;
920 + }
745 921
746 - (new OrderStatusUpdated($order, $oldStatus, $order->status, true, $actionActivity, 'order_status'))->dispatch();
922 + (new StatusHelper($locked))->syncOrderStatuses($transaction);
747 923
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();
924 + $paymentNote = sanitize_textarea_field($request->get('mark_paid_note', ''));
925 + if ($paymentNote) {
926 + $locked->addLog(
927 + __('Payment note', 'fluent-cart'),
928 + nl2br(esc_html($paymentNote)),
929 + 'info',
930 + wp_get_current_user()->display_name
931 + );
762 932 }
763 933
764 934 return $this->response->sendSuccess([
765 935 'message' => __('Order has been marked as paid', 'fluent-cart')
@@ -787,11 +957,8 @@
787 957 'message' => __('Orders selection is required', 'fluent-cart')
788 958 ]);
789 959 }
790 960
791 - $orders = Order::query()->whereIn('id', $orderIds)->get();
792 -
793 -
794 961 if ($action == 'delete_orders') {
795 962
796 963 $isDeleted = OrderResource::bulkDeleteByOrderIds($orderIds);
797 964
@@ -823,168 +990,17 @@
823 990 // }
824 991
825 992
826 993 }
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 994
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 - }
995 + // The capture_payments branch was removed: it called
996 + // $order->capturePayments(), a method that has never existed anywhere
997 + // in the codebase, so the action fataled on the first order (audit
998 + // item #43). No UI sends it — the orders bulk bar submits
999 + // delete_test_orders only. Bulk payment capture, if wanted, is a
1000 + // gateway feature to design (authorize/capture per gateway), not a
1001 + // branch to resurrect as-is.
841 1002
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 1003 return $this->sendError([
988 1004 'message' => __('Selected action is invalid', 'fluent-cart')
989 1005 ]);
990 1006
@@ -1062,9 +1078,17 @@
1062 1078 public function updateTransactionStatus(Request $request, $order, OrderTransaction $transaction)
1063 1079 {
1064 1080
1065 1081 $order = Order::query()->find($order);
1066 - $newStatus = $request->get('status');
1082 + $newStatus = sanitize_text_field($request->get('status', ''));
1083 +
1084 + $validStatuses = Status::getEditableTransactionStatuses();
1085 + if (!isset($validStatuses[$newStatus])) {
1086 + return $this->sendError([
1087 + 'message' => __('Provided transaction status is not valid', 'fluent-cart')
1088 + ]);
1089 + }
1090 +
1067 1091 if ($transaction->status == $newStatus) {
1068 1092 return $this->sendError([
1069 1093 'reload' => true,
1070 1094 'message' => __('Transaction already has the same status', 'fluent-cart')
@@ -1076,11 +1100,26 @@
1076 1100 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart')
1077 1101 ]);
1078 1102 }
1079 1103
1104 + // Money already counted into the order cannot be changed from a dropdown; returning
1105 + // it is a refund, which records a refund transaction through the refund action (FC-SEC-09).
1106 + if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
1107 + return $this->sendError([
1108 + 'message' => __('A succeeded transaction cannot be changed here. Use the refund action to return the payment.', 'fluent-cart')
1109 + ], 422);
1110 + }
1111 +
1080 1112 $transaction->updateStatus($newStatus);
1081 - $order->updatePaymentStatus($newStatus);
1082 1113
1114 + if ($newStatus === Status::TRANSACTION_SUCCEEDED) {
1115 + // 'succeeded' is a transaction word, not an order payment status: derive the
1116 + // order's paid state and total_paid from its transactions, as mark-as-paid does.
1117 + (new StatusHelper($order))->syncOrderStatuses($transaction);
1118 + } else {
1119 + $order->updatePaymentStatus($newStatus);
1120 + }
1121 +
1083 1122 return [
1084 1123 'transaction' => $transaction,
1085 1124 'message' => __('Payment status has been successfully updated', 'fluent-cart')
1086 1125 ];
@@ -1085,8 +1124,32 @@
1085 1124 'message' => __('Payment status has been successfully updated', 'fluent-cart')
1086 1125 ];
1087 1126 }
1088 1127
1128 + public function syncPendingTransaction(Request $request, $order, OrderTransaction $transaction)
1129 + {
1130 + $order = Order::query()->find($order);
1131 +
1132 + if (!$order || $transaction->order_id != $order->id) {
1133 + return $this->sendError([
1134 + 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart')
1135 + ]);
1136 + }
1137 +
1138 + $result = $transaction->syncPendingTransaction();
1139 +
1140 + if (is_wp_error($result)) {
1141 + return $this->sendError([
1142 + 'message' => $result->get_error_message()
1143 + ]);
1144 + }
1145 +
1146 + return $this->sendSuccess([
1147 + 'message' => __('Transaction has been synced from the payment gateway successfully!', 'fluent-cart'),
1148 + 'transaction' => $result
1149 + ]);
1150 + }
1151 +
1089 1152 public function getStats($orderUuid): \WP_REST_Response
1090 1153 {
1091 1154 $order = OrderResource::find($orderUuid);
1092 1155 return $this->sendSuccess([
@@ -1146,8 +1209,74 @@
1146 1209 'shipping_charge' => $totalShippingCharge,
1147 1210 'order_items' => $orderItems
1148 1211 ]);
1149 1212
1213 + }
1214 +
1215 + /**
1216 + * Calculate tax for an admin order given an address and item list.
1217 + * No DB writes — pure calculation helper.
1218 + *
1219 + * Accepts: { country, state, city, postcode, items: [{post_id, object_id, subtotal, discount_total}] }
1220 + * Returns: { tax_total, shipping_tax, tax_lines, tax_behavior, tax_country }
1221 + */
1222 + public function calculateTax(Request $request)
1223 + {
1224 + $address = [
1225 + 'country' => sanitize_text_field($request->get('country', '')),
1226 + 'state' => sanitize_text_field($request->get('state', '')),
1227 + 'city' => sanitize_text_field($request->get('city', '')),
1228 + 'postcode' => sanitize_text_field($request->get('postcode', '')),
1229 + ];
1230 +
1231 + $rawItems = $request->get('items', []);
1232 + if (!is_array($rawItems)) {
1233 + $rawItems = [];
1234 + } elseif (count($rawItems) > 100) {
1235 + $rawItems = array_slice($rawItems, 0, 100);
1236 + }
1237 +
1238 + $items = [];
1239 + foreach ($rawItems as $item) {
1240 + $items[] = [
1241 + 'post_id' => (int) Arr::get($item, 'post_id', 0),
1242 + 'object_id' => (int) Arr::get($item, 'object_id', 0),
1243 + 'subtotal' => (int) Arr::get($item, 'subtotal', 0),
1244 + 'discount_total' => (int) Arr::get($item, 'discount_total', 0),
1245 + 'shipping_charge' => (int) Arr::get($item, 'shipping_charge', 0),
1246 + 'quantity' => max(1, (int) Arr::get($item, 'quantity', 1)),
1247 + ];
1248 + }
1249 +
1250 + $result = \FluentCart\App\Services\Tax\AdminOrderTaxService::calculate($items, $address);
1251 +
1252 + if ($result === null) {
1253 + return $this->sendSuccess([
1254 + 'tax_total' => 0,
1255 + 'shipping_tax' => 0,
1256 + 'tax_lines' => [],
1257 + 'tax_behavior' => 0,
1258 + 'tax_country' => '',
1259 + ]);
1260 + }
1261 +
1262 + $taxLines = Arr::get($result, 'tax_lines', []);
1263 + $strippedTaxLines = array_values(array_map(function ($line) {
1264 + return [
1265 + 'label' => Arr::get($line, 'label', ''),
1266 + 'rate_percent' => Arr::get($line, 'rate_percent', 0),
1267 + 'tax_amount' => (int) Arr::get($line, 'tax_amount', 0),
1268 + 'inclusive' => (bool) Arr::get($line, 'inclusive', false),
1269 + ];
1270 + }, $taxLines));
1271 +
1272 + return $this->sendSuccess([
1273 + 'tax_total' => (int) Arr::get($result, 'tax_total', 0),
1274 + 'shipping_tax' => (int) Arr::get($result, 'shipping_tax', 0),
1275 + 'tax_behavior' => (int) Arr::get($result, 'tax_behavior', 0),
1276 + 'tax_country' => Arr::get($result, 'tax_country', ''),
1277 + 'tax_lines' => $strippedTaxLines,
1278 + ]);
1150 1279 }
1151 1280
1152 1281 protected function prepareOrderItemsWithVariations($orderItems)
1153 1282 {