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 +380 -265 1.3.27 → 1.7.1 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
@@ -138,9 +211,12 @@
138 211 ], 400);
139 212 }
140 213
141 214
142 - $requestData = $request->getSafe($request->sanitize());
215 + $requestData = array_intersect_key(
216 + $request->getSafe($request->sanitize()),
217 + $request->all()
218 + );
143 219
144 220 $totalPaid = Arr::get($request->all(), 'total_paid');
145 221 $updatedTotal = Arr::get($requestData, 'total_amount');
146 222
@@ -161,9 +237,9 @@
161 237 // if new shipping total is already adjusted in total amount, then no need to adjust again, right now not adjusted before
162 238 // ToDo: adjust changed shipping total in total amount prior to this
163 239 $shippingTotal = Arr::get($requestData, 'shipping_total', 0);
164 240 $oldShippingTotal = Arr::get($order, 'shipping_total', 0);
165 - if ($shippingTotal != $oldShippingTotal) {
241 + if (array_key_exists('shipping_total', $requestData) && $shippingTotal != $oldShippingTotal) {
166 242 $diff = $shippingTotal - $oldShippingTotal;
167 243 if ($diff < 0) {
168 244 $requestData['total_amount'] = $updatedTotal - abs($diff);
169 245 } else {
@@ -205,13 +281,29 @@
205 281
206 282 if (is_wp_error($data)) {
207 283 return $this->sendError($data->get_error_message());
208 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 +
209 302 return $this->sendSuccess([
210 - 'message' => 'Address updated successfully'
303 + 'message' => __('Address updated successfully', 'fluent-cart'),
304 + 'order' => $freshOrder,
211 305 ]);
212 -
213 -
214 306 }
215 307
216 308 public function generateMissingLicenses(Request $request, Order $order)
217 309 {
@@ -236,8 +328,14 @@
236 328
237 329 }
238 330
239 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 + *
240 338 * @throws ValidationException
241 339 */
242 340 public function refundOrder(Request $request, $orderId)
243 341 {
@@ -248,11 +346,16 @@
248 346 'message' => __('Order can not be refunded.', 'fluent-cart')
249 347 ], 400);
250 348 }
251 349
252 - $refundInfo = $request->get('refund_info', []);
350 + $refundInfo = (array)$request->get('refund_info', []);
253 351
254 - $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, [
255 358 'transaction_id' => 'required',
256 359 'amount' => 'required',
257 360 ], [
258 361 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'),
@@ -258,10 +361,14 @@
258 361 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'),
259 362 'amount.required' => __('Refund amount is required', 'fluent-cart'),
260 363 ]);
261 364
365 + if ($validator->validate()->fails()) {
366 + return $this->sendError($validator->errors(), 422);
367 + }
368 +
262 369 $transaction = OrderTransaction::query()->where('order_id', $orderId)->findOrFail($refundInfo['transaction_id']);
263 - $refundAmount = Helper::toCent($refundInfo['amount']);
370 + $refundAmount = Helper::roundCent($refundInfo['amount']);
264 371
265 372 // refund on our end
266 373 $result = (new Refund())->processRefund($transaction, $refundAmount, $refundInfo);
267 374
@@ -520,9 +627,9 @@
520 627 // Must run before deleteOrderRelatedData() which removes stock_movement meta and order items.
521 628 (new OrderDeleting($order, $connectedOrderIds, $isTestMode, $order->type))->dispatch();
522 629
523 630 // Pre-load relations before cleanup so the delete events have address data
524 - $order->load('customer', 'shipping_address', 'billing_address');
631 + $order->load(['customer', 'shipping_address', 'billing_address']);
525 632
526 633 $this->deleteOrderRelatedData($connectedOrderIds, $isTestMode);
527 634 $DB->commit();
528 635 } catch (\Exception $e) {
@@ -616,18 +723,52 @@
616 723
617 724 if (empty($data['order']['receipt_url'])) {
618 725 $data['order']['receipt_url'] = $url;
619 726 }
620 - $meta = OrderMeta::query()->where('order_id', $orderId)
621 - ->where('meta_key', 'vat_tax_id')
622 - ->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']);
623 732
624 - if ($meta) {
625 - $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 + );
626 754 }
627 755
628 - $data['can_send_payment_reminder'] = (new ReminderService())->canSendPaymentReminder($data['order']);
756 + $data = $this->getDetails($orderId);
629 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 +
630 771 return $data;
631 772 }
632 773
633 774 public function createCustom(Request $request, OrderItemHelper $orderItemHelper, Order $order)
@@ -632,13 +773,18 @@
632 773
633 774 public function createCustom(Request $request, OrderItemHelper $orderItemHelper, Order $order)
634 775 {
635 776 try {
636 - return $orderItemHelper->processCustom(
777 + $orderItem = $orderItemHelper->processCustom(
637 778 $request->product,
638 779 $order->id
639 780 );
640 781
782 + return $this->sendSuccess([
783 + 'message' => __('Custom item has been added to the order!', 'fluent-cart'),
784 + 'order_item' => $orderItem
785 + ]);
786 +
641 787 } catch (\Exception $e) {
642 788 return $this->sendError([
643 789 'message' => $e->getMessage()
644 790 ], 423);
@@ -683,97 +829,107 @@
683 829
684 830
685 831 public function markAsPaid(Request $request, Order $order)
686 832 {
687 - $dueAmount = intval($order->total_amount - $order->total_paid);
833 + $db = Order::query()->getConnection();
834 + $db->beginTransaction();
688 835
689 - if ($dueAmount <= 0) {
690 - return $this->sendError([
691 - 'message' => __('Order has already been paid', 'fluent-cart')
692 - ], 423);
693 - }
836 + try {
837 + $locked = Order::query()
838 + ->where('id', $order->id)
839 + ->lockForUpdate()
840 + ->first();
694 841
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 - }
842 + if (!$locked) {
843 + $db->rollBack();
844 + return $this->sendError([
845 + 'message' => __('Order not found', 'fluent-cart')
846 + ], 404);
847 + }
700 848
701 - $transaction = $order->transactions->where('status', Status::TRANSACTION_PENDING)
702 - ->where('payment_method', 'offline_payment')
703 - ->first();
849 + $dueAmount = intval($locked->total_amount - $locked->total_paid);
704 850
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 - ];
851 + if ($dueAmount <= 0) {
852 + $db->rollBack();
853 + return $this->sendError([
854 + 'message' => __('Order has already been paid', 'fluent-cart')
855 + ], 423);
856 + }
716 857
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 - }
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 + }
726 864
727 - $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();
728 878
729 - $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 + ];
730 889
731 - if ($order->payment_status !== 'partially_refunded') {
732 - $order->payment_status = Status::PAYMENT_PAID;
733 - }
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 + }
734 902
735 - $order->status = Status::ORDER_PROCESSING;
736 - $order->total_paid = $order->total_amount;
737 - $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');
738 913
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 - ];
914 + $locked->save();
745 915
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 - }
916 + $db->commit();
917 + } catch (\Throwable $e) {
918 + $db->rollBack();
919 + throw $e;
760 920 }
761 921
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();
922 + (new StatusHelper($locked))->syncOrderStatuses($transaction);
767 923
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();
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 + );
776 932 }
777 933
778 934 return $this->response->sendSuccess([
779 935 'message' => __('Order has been marked as paid', 'fluent-cart')
@@ -801,11 +957,8 @@
801 957 'message' => __('Orders selection is required', 'fluent-cart')
802 958 ]);
803 959 }
804 960
805 - $orders = Order::query()->whereIn('id', $orderIds)->get();
806 -
807 -
808 961 if ($action == 'delete_orders') {
809 962
810 963 $isDeleted = OrderResource::bulkDeleteByOrderIds($orderIds);
811 964
@@ -837,168 +990,17 @@
837 990 // }
838 991
839 992
840 993 }
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 994
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 - }
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.
855 1002
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 1003 return $this->sendError([
1002 1004 'message' => __('Selected action is invalid', 'fluent-cart')
1003 1005 ]);
1004 1006
@@ -1076,9 +1078,17 @@
1076 1078 public function updateTransactionStatus(Request $request, $order, OrderTransaction $transaction)
1077 1079 {
1078 1080
1079 1081 $order = Order::query()->find($order);
1080 - $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 +
1081 1091 if ($transaction->status == $newStatus) {
1082 1092 return $this->sendError([
1083 1093 'reload' => true,
1084 1094 'message' => __('Transaction already has the same status', 'fluent-cart')
@@ -1090,11 +1100,26 @@
1090 1100 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart')
1091 1101 ]);
1092 1102 }
1093 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 +
1094 1112 $transaction->updateStatus($newStatus);
1095 - $order->updatePaymentStatus($newStatus);
1096 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 +
1097 1122 return [
1098 1123 'transaction' => $transaction,
1099 1124 'message' => __('Payment status has been successfully updated', 'fluent-cart')
1100 1125 ];
@@ -1099,8 +1124,32 @@
1099 1124 'message' => __('Payment status has been successfully updated', 'fluent-cart')
1100 1125 ];
1101 1126 }
1102 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 +
1103 1152 public function getStats($orderUuid): \WP_REST_Response
1104 1153 {
1105 1154 $order = OrderResource::find($orderUuid);
1106 1155 return $this->sendSuccess([
@@ -1160,8 +1209,74 @@
1160 1209 'shipping_charge' => $totalShippingCharge,
1161 1210 'order_items' => $orderItems
1162 1211 ]);
1163 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 + ]);
1164 1279 }
1165 1280
1166 1281 protected function prepareOrderItemsWithVariations($orderItems)
1167 1282 {