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 +281 -103 1.5.5 → 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\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
@@ -137,9 +211,12 @@
137 211 ], 400);
138 212 }
139 213
140 214
141 - $requestData = $request->getSafe($request->sanitize());
215 + $requestData = array_intersect_key(
216 + $request->getSafe($request->sanitize()),
217 + $request->all()
218 + );
142 219
143 220 $totalPaid = Arr::get($request->all(), 'total_paid');
144 221 $updatedTotal = Arr::get($requestData, 'total_amount');
145 222
@@ -160,9 +237,9 @@
160 237 // if new shipping total is already adjusted in total amount, then no need to adjust again, right now not adjusted before
161 238 // ToDo: adjust changed shipping total in total amount prior to this
162 239 $shippingTotal = Arr::get($requestData, 'shipping_total', 0);
163 240 $oldShippingTotal = Arr::get($order, 'shipping_total', 0);
164 - if ($shippingTotal != $oldShippingTotal) {
241 + if (array_key_exists('shipping_total', $requestData) && $shippingTotal != $oldShippingTotal) {
165 242 $diff = $shippingTotal - $oldShippingTotal;
166 243 if ($diff < 0) {
167 244 $requestData['total_amount'] = $updatedTotal - abs($diff);
168 245 } else {
@@ -251,8 +328,14 @@
251 328
252 329 }
253 330
254 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 + *
255 338 * @throws ValidationException
256 339 */
257 340 public function refundOrder(Request $request, $orderId)
258 341 {
@@ -263,11 +346,16 @@
263 346 'message' => __('Order can not be refunded.', 'fluent-cart')
264 347 ], 400);
265 348 }
266 349
267 - $refundInfo = $request->get('refund_info', []);
350 + $refundInfo = (array)$request->get('refund_info', []);
268 351
269 - $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, [
270 358 'transaction_id' => 'required',
271 359 'amount' => 'required',
272 360 ], [
273 361 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'),
@@ -273,10 +361,14 @@
273 361 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'),
274 362 'amount.required' => __('Refund amount is required', 'fluent-cart'),
275 363 ]);
276 364
365 + if ($validator->validate()->fails()) {
366 + return $this->sendError($validator->errors(), 422);
367 + }
368 +
277 369 $transaction = OrderTransaction::query()->where('order_id', $orderId)->findOrFail($refundInfo['transaction_id']);
278 - $refundAmount = Helper::toCent($refundInfo['amount']);
370 + $refundAmount = Helper::roundCent($refundInfo['amount']);
279 371
280 372 // refund on our end
281 373 $result = (new Refund())->processRefund($transaction, $refundAmount, $refundInfo);
282 374
@@ -642,16 +734,57 @@
642 734
643 735 return $data;
644 736 }
645 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 + );
754 + }
755 +
756 + $data = $this->getDetails($orderId);
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 +
771 + return $data;
772 + }
773 +
646 774 public function createCustom(Request $request, OrderItemHelper $orderItemHelper, Order $order)
647 775 {
648 776 try {
649 - return $orderItemHelper->processCustom(
777 + $orderItem = $orderItemHelper->processCustom(
650 778 $request->product,
651 779 $order->id
652 780 );
653 781
782 + return $this->sendSuccess([
783 + 'message' => __('Custom item has been added to the order!', 'fluent-cart'),
784 + 'order_item' => $orderItem
785 + ]);
786 +
654 787 } catch (\Exception $e) {
655 788 return $this->sendError([
656 789 'message' => $e->getMessage()
657 790 ], 423);
@@ -696,97 +829,107 @@
696 829
697 830
698 831 public function markAsPaid(Request $request, Order $order)
699 832 {
700 - $dueAmount = intval($order->total_amount - $order->total_paid);
833 + $db = Order::query()->getConnection();
834 + $db->beginTransaction();
701 835
702 - if ($dueAmount <= 0) {
703 - return $this->sendError([
704 - 'message' => __('Order has already been paid', 'fluent-cart')
705 - ], 423);
706 - }
836 + try {
837 + $locked = Order::query()
838 + ->where('id', $order->id)
839 + ->lockForUpdate()
840 + ->first();
707 841
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 - }
842 + if (!$locked) {
843 + $db->rollBack();
844 + return $this->sendError([
845 + 'message' => __('Order not found', 'fluent-cart')
846 + ], 404);
847 + }
713 848
714 - $transaction = $order->transactions->where('status', Status::TRANSACTION_PENDING)
715 - ->where('payment_method', 'offline_payment')
716 - ->first();
849 + $dueAmount = intval($locked->total_amount - $locked->total_paid);
717 850
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 - ];
851 + if ($dueAmount <= 0) {
852 + $db->rollBack();
853 + return $this->sendError([
854 + 'message' => __('Order has already been paid', 'fluent-cart')
855 + ], 423);
856 + }
729 857
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 - }
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 + }
739 864
740 - $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();
741 878
742 - $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 + ];
743 889
744 - if ($order->payment_status !== 'partially_refunded') {
745 - $order->payment_status = Status::PAYMENT_PAID;
746 - }
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 + }
747 902
748 - $order->status = Status::ORDER_PROCESSING;
749 - $order->total_paid = $order->total_amount;
750 - $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');
751 913
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 - ];
914 + $locked->save();
758 915
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 - }
916 + $db->commit();
917 + } catch (\Throwable $e) {
918 + $db->rollBack();
919 + throw $e;
773 920 }
774 921
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();
922 + (new StatusHelper($locked))->syncOrderStatuses($transaction);
780 923
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();
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 + );
789 932 }
790 933
791 934 return $this->response->sendSuccess([
792 935 'message' => __('Order has been marked as paid', 'fluent-cart')
@@ -814,11 +957,8 @@
814 957 'message' => __('Orders selection is required', 'fluent-cart')
815 958 ]);
816 959 }
817 960
818 - $orders = Order::query()->whereIn('id', $orderIds)->get();
819 -
820 -
821 961 if ($action == 'delete_orders') {
822 962
823 963 $isDeleted = OrderResource::bulkDeleteByOrderIds($orderIds);
824 964
@@ -850,17 +990,16 @@
850 990 // }
851 991
852 992
853 993 }
854 - if ($action == 'capture_payments') {
855 - foreach ($orders as $order) {
856 - $order->capturePayments();
857 - }
858 994
859 - return [
860 - 'message' => __('Selected payments has been successfully captured', 'fluent-cart')
861 - ];
862 - }
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.
863 1002
864 1003 return $this->sendError([
865 1004 'message' => __('Selected action is invalid', 'fluent-cart')
866 1005 ]);
@@ -961,15 +1100,54 @@
961 1100 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart')
962 1101 ]);
963 1102 }
964 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 +
965 1112 $transaction->updateStatus($newStatus);
966 - $order->updatePaymentStatus($newStatus);
967 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 +
968 1122 return [
969 1123 'transaction' => $transaction,
970 1124 'message' => __('Payment status has been successfully updated', 'fluent-cart')
971 1125 ];
1126 + }
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 + ]);
972 1150 }
973 1151
974 1152 public function getStats($orderUuid): \WP_REST_Response
975 1153 {