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 +272 -103 1.5.4 → 1.6.5 View file →
@@ -5,14 +5,11 @@
5 5
6 6 use FluentCart\Api\Resource\CustomerResource;
7 7 use FluentCart\Api\Resource\OrderResource;
8 8 use FluentCart\Api\StoreSettings;
9 -use FluentCart\App\Events\Subscription\SubscriptionActivated;
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;
@@ -39,12 +36,10 @@
39 36 use FluentCart\App\Models\OrderDownloadPermission;
40 37 use FluentCart\App\Models\Subscription;
41 38 use FluentCart\App\Models\SubscriptionMeta;
42 39 use FluentCart\App\Services\Filter\OrderFilter;
43 -use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
44 40 use FluentCart\App\Services\Payments\PaymentHelper;
45 41 use FluentCart\App\Services\Reminders\ReminderService;
46 -use FluentCart\App\Services\DateTime\DateTime;
47 42 use FluentCart\App\Services\Payments\Refund;
48 43 use FluentCart\App\Services\URL;
49 44 use FluentCart\Framework\Http\Request\Request;
50 45 use FluentCart\Framework\Support\Arr;
@@ -79,14 +74,27 @@
79 74 public function store(OrderRequest $request)
80 75 {
81 76 $data = $request->getSafe($request->sanitize());
82 77 $type = 'payment';
83 - $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);
84 92 if ($hasSubscription) {
85 93 $type = 'subscription';
86 94 // right now we don't support subscription with manual order
87 - $isSubscriptionAllowedInManualOrder = apply_filters('fluent_cart/order/is_subscription_allowed_in_manual_order', false, [
88 - '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
89 97 ]);
90 98
91 99 if (!$isSubscriptionAllowedInManualOrder) {
92 100 return $this->sendError([
@@ -114,20 +122,86 @@
114 122 'uuid' => $order->uuid
115 123 ]);
116 124 }
117 125
118 -
119 126 public static function hasSubscription($orderItems): bool
120 127 {
121 - // check order items for subscription, payment_type == subscription
122 128 foreach ($orderItems as $item) {
123 129 if (Arr::get($item, 'payment_type') == 'subscription' || Arr::get($item, 'other_info.payment_type') == 'subscription') {
124 130 return true;
125 131 }
126 132 }
133 +
127 134 return false;
128 135 }
129 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 +
130 204 public function updateOrder(OrderRequest $request, $order_id)
131 205 {
132 206 $order = Order::query()->find($order_id);
133 207
@@ -251,8 +325,14 @@
251 325
252 326 }
253 327
254 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 + *
255 335 * @throws ValidationException
256 336 */
257 337 public function refundOrder(Request $request, $orderId)
258 338 {
@@ -263,11 +343,16 @@
263 343 'message' => __('Order can not be refunded.', 'fluent-cart')
264 344 ], 400);
265 345 }
266 346
267 - $refundInfo = $request->get('refund_info', []);
347 + $refundInfo = (array)$request->get('refund_info', []);
268 348
269 - $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, [
270 355 'transaction_id' => 'required',
271 356 'amount' => 'required',
272 357 ], [
273 358 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'),
@@ -273,10 +358,14 @@
273 358 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'),
274 359 'amount.required' => __('Refund amount is required', 'fluent-cart'),
275 360 ]);
276 361
362 + if ($validator->validate()->fails()) {
363 + return $this->sendError($validator->errors(), 422);
364 + }
365 +
277 366 $transaction = OrderTransaction::query()->where('order_id', $orderId)->findOrFail($refundInfo['transaction_id']);
278 - $refundAmount = Helper::toCent($refundInfo['amount']);
367 + $refundAmount = Helper::roundCent($refundInfo['amount']);
279 368
280 369 // refund on our end
281 370 $result = (new Refund())->processRefund($transaction, $refundAmount, $refundInfo);
282 371
@@ -642,16 +731,57 @@
642 731
643 732 return $data;
644 733 }
645 734
735 + public function getTransactionDetails($orderId, $transactionId)
736 + {
737 + $orderId = (int)$orderId;
738 + $transactionId = (int)$transactionId;
739 +
740 + $belongsToOrder = OrderTransaction::query()
741 + ->where('id', $transactionId)
742 + ->where('order_id', $orderId)
743 + ->exists();
744 +
745 + if (!$belongsToOrder) {
746 + return $this->entityNotFoundError(
747 + __('Transaction not found', 'fluent-cart'),
748 + __('Back to orders', 'fluent-cart'),
749 + '/orders'
750 + );
751 + }
752 +
753 + $data = $this->getDetails($orderId);
754 +
755 + if (!is_array($data) || empty($data['order'])) {
756 + return $data;
757 + }
758 +
759 + // The path names one transaction, so the sibling rows on the same order
760 + // are not part of this response.
761 + $data['order']['transactions'] = array_values(array_filter(
762 + (array)Arr::get($data, 'order.transactions', []),
763 + function ($transaction) use ($transactionId) {
764 + return (int)Arr::get($transaction, 'id') === $transactionId;
765 + }
766 + ));
767 +
768 + return $data;
769 + }
770 +
646 771 public function createCustom(Request $request, OrderItemHelper $orderItemHelper, Order $order)
647 772 {
648 773 try {
649 - return $orderItemHelper->processCustom(
774 + $orderItem = $orderItemHelper->processCustom(
650 775 $request->product,
651 776 $order->id
652 777 );
653 778
779 + return $this->sendSuccess([
780 + 'message' => __('Custom item has been added to the order!', 'fluent-cart'),
781 + 'order_item' => $orderItem
782 + ]);
783 +
654 784 } catch (\Exception $e) {
655 785 return $this->sendError([
656 786 'message' => $e->getMessage()
657 787 ], 423);
@@ -696,99 +826,103 @@
696 826
697 827
698 828 public function markAsPaid(Request $request, Order $order)
699 829 {
700 - $dueAmount = intval($order->total_amount - $order->total_paid);
830 + $db = Order::query()->getConnection();
831 + $db->beginTransaction();
701 832
702 - if ($dueAmount <= 0) {
703 - return $this->sendError([
704 - 'message' => __('Order has already been paid', 'fluent-cart')
705 - ], 423);
706 - }
833 + try {
834 + $locked = Order::query()
835 + ->where('id', $order->id)
836 + ->lockForUpdate()
837 + ->first();
707 838
708 - if (Arr::get($order, 'status') === 'canceled') {
709 - return $this->sendError([
710 - 'message' => __('Unable to mark paid for canceled order', 'fluent-cart')
711 - ], 423);
712 - }
839 + if (!$locked) {
840 + $db->rollBack();
841 + return $this->sendError([
842 + 'message' => __('Order not found', 'fluent-cart')
843 + ], 404);
844 + }
713 845
714 - $transaction = $order->transactions->where('status', Status::TRANSACTION_PENDING)
715 - ->where('payment_method', 'offline_payment')
716 - ->first();
846 + $dueAmount = intval($locked->total_amount - $locked->total_paid);
717 847
718 - $newTransactionData = [
719 - 'total' => $dueAmount,
720 - 'status' => Status::TRANSACTION_SUCCEEDED,
721 - 'payment_method' => sanitize_text_field($request->payment_method),
722 - 'vendor_charge_id' => sanitize_text_field($request->vendor_charge_id),
723 - 'payment_mode' => sanitize_text_field($order->mode),
724 - 'payment_method_type' => sanitize_text_field($request->payment_method),
725 - 'order_type' => sanitize_text_field($order->type),
726 - 'transaction_type' => sanitize_text_field($request->transaction_type),
727 - 'currency' => sanitize_text_field($order->currency),
728 - ];
848 + if ($dueAmount <= 0) {
849 + $db->rollBack();
850 + return $this->sendError([
851 + 'message' => __('Order has already been paid', 'fluent-cart')
852 + ], 423);
853 + }
729 854
730 - if ($transaction) {
731 - $transaction->update($newTransactionData);
732 - } else {
733 - $transaction = OrderTransaction::query()->create(
734 - array_merge($newTransactionData, [
735 - 'order_id' => $order->id
736 - ])
737 - );
738 - }
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 + }
739 861
740 - $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();
741 875
742 - $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 + ];
743 886
744 - if ($order->payment_status !== 'partially_refunded') {
745 - $order->payment_status = Status::PAYMENT_PAID;
746 - }
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 + }
747 899
748 - $order->status = Status::ORDER_PROCESSING;
749 - $order->total_paid = $order->total_amount;
750 - $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');
751 910
752 - $actionActivity = [
753 - 'title' => __('Order status updated', 'fluent-cart'),
754 - 'content' => sprintf(
755 - /* translators: 1: old status, 2: new status */
756 - __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), $oldStatus, $order->status)
757 - ];
911 + $note = sanitize_text_field($request->get('mark_paid_note', ''));
912 + if ($note) {
913 + $locked->note = $note;
914 + }
915 + $locked->save();
758 916
759 - // dispatching events related to order status update and payment paid
760 - (new OrderPaid($order, $order->customer, $transaction))->dispatch();
761 -
762 - (new OrderStatusUpdated($order, $oldStatus, $order->status, true, $actionActivity, 'order_status'))->dispatch();
763 -
764 - if ($order->type === 'subscription') {
765 - $subscription = Subscription::query()->where('parent_order_id', $order->id)->first();
766 - if ($subscription) {
767 - $oldSubStatus = $subscription->status;
768 - $subscription = SubscriptionService::syncSubscriptionStates($subscription, ['status' => Status::SUBSCRIPTION_ACTIVE]);
769 - if ($oldSubStatus !== Status::SUBSCRIPTION_ACTIVE && $subscription->status === Status::SUBSCRIPTION_ACTIVE) {
770 - (new SubscriptionActivated($subscription, $order, $order->customer))->dispatch();
771 - }
772 - }
917 + $db->commit();
918 + } catch (\Throwable $e) {
919 + $db->rollBack();
920 + throw $e;
773 921 }
774 922
775 - // if digital
776 - if ($order->fulfillment_type == 'digital' && $order->status === Status::ORDER_PROCESSING) {
777 - $order->status = Status::ORDER_COMPLETED;
778 - $order->completed_at = DateTime::gmtNow();
779 - $order->save();
923 + (new StatusHelper($locked))->syncOrderStatuses($transaction);
780 924
781 - $actionActivity = [
782 - 'title' => __('Order status updated', 'fluent-cart'),
783 - 'content' => sprintf(
784 - /* translators: 1: old status, 2: new status */
785 - __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), Status::ORDER_PROCESSING, $order->status)
786 - ];
787 -
788 - (new OrderStatusUpdated($order, Status::ORDER_PROCESSING, $order->status, true, $actionActivity, 'order_status'))->dispatch();
789 - }
790 -
791 925 return $this->response->sendSuccess([
792 926 'message' => __('Order has been marked as paid', 'fluent-cart')
793 927 ]);
794 928 }
@@ -814,11 +948,8 @@
814 948 'message' => __('Orders selection is required', 'fluent-cart')
815 949 ]);
816 950 }
817 951
818 - $orders = Order::query()->whereIn('id', $orderIds)->get();
819 -
820 -
821 952 if ($action == 'delete_orders') {
822 953
823 954 $isDeleted = OrderResource::bulkDeleteByOrderIds($orderIds);
824 955
@@ -850,17 +981,16 @@
850 981 // }
851 982
852 983
853 984 }
854 - if ($action == 'capture_payments') {
855 - foreach ($orders as $order) {
856 - $order->capturePayments();
857 - }
858 985
859 - return [
860 - 'message' => __('Selected payments has been successfully captured', 'fluent-cart')
861 - ];
862 - }
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.
863 993
864 994 return $this->sendError([
865 995 'message' => __('Selected action is invalid', 'fluent-cart')
866 996 ]);
@@ -961,15 +1091,54 @@
961 1091 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart')
962 1092 ]);
963 1093 }
964 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 +
965 1103 $transaction->updateStatus($newStatus);
966 - $order->updatePaymentStatus($newStatus);
967 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 +
968 1113 return [
969 1114 'transaction' => $transaction,
970 1115 'message' => __('Payment status has been successfully updated', 'fluent-cart')
971 1116 ];
1117 + }
1118 +
1119 + public function syncPendingTransaction(Request $request, $order, OrderTransaction $transaction)
1120 + {
1121 + $order = Order::query()->find($order);
1122 +
1123 + if (!$order || $transaction->order_id != $order->id) {
1124 + return $this->sendError([
1125 + 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart')
1126 + ]);
1127 + }
1128 +
1129 + $result = $transaction->syncPendingTransaction();
1130 +
1131 + if (is_wp_error($result)) {
1132 + return $this->sendError([
1133 + 'message' => $result->get_error_message()
1134 + ]);
1135 + }
1136 +
1137 + return $this->sendSuccess([
1138 + 'message' => __('Transaction has been synced from the payment gateway successfully!', 'fluent-cart'),
1139 + 'transaction' => $result
1140 + ]);
972 1141 }
973 1142
974 1143 public function getStats($orderUuid): \WP_REST_Response
975 1144 {