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 +263 -67 1.6.0 → 1.7.1 View file →
@@ -74,14 +74,27 @@
74 74 public function store(OrderRequest $request)
75 75 {
76 76 $data = $request->getSafe($request->sanitize());
77 77 $type = 'payment';
78 - $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);
79 92 if ($hasSubscription) {
80 93 $type = 'subscription';
81 94 // right now we don't support subscription with manual order
82 95 $isSubscriptionAllowedInManualOrder = apply_filters('fluent_cart/order/is_subscription_allowed_in_manual_order', true, [
83 - 'order_items' => Arr::get($data, 'order_items', [])
96 + 'order_items' => $orderItems
84 97 ]);
85 98
86 99 if (!$isSubscriptionAllowedInManualOrder) {
87 100 return $this->sendError([
@@ -109,20 +122,86 @@
109 122 'uuid' => $order->uuid
110 123 ]);
111 124 }
112 125
113 -
114 126 public static function hasSubscription($orderItems): bool
115 127 {
116 - // check order items for subscription, payment_type == subscription
117 128 foreach ($orderItems as $item) {
118 129 if (Arr::get($item, 'payment_type') == 'subscription' || Arr::get($item, 'other_info.payment_type') == 'subscription') {
119 130 return true;
120 131 }
121 132 }
133 +
122 134 return false;
123 135 }
124 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 +
125 204 public function updateOrder(OrderRequest $request, $order_id)
126 205 {
127 206 $order = Order::query()->find($order_id);
128 207
@@ -132,9 +211,12 @@
132 211 ], 400);
133 212 }
134 213
135 214
136 - $requestData = $request->getSafe($request->sanitize());
215 + $requestData = array_intersect_key(
216 + $request->getSafe($request->sanitize()),
217 + $request->all()
218 + );
137 219
138 220 $totalPaid = Arr::get($request->all(), 'total_paid');
139 221 $updatedTotal = Arr::get($requestData, 'total_amount');
140 222
@@ -155,9 +237,9 @@
155 237 // if new shipping total is already adjusted in total amount, then no need to adjust again, right now not adjusted before
156 238 // ToDo: adjust changed shipping total in total amount prior to this
157 239 $shippingTotal = Arr::get($requestData, 'shipping_total', 0);
158 240 $oldShippingTotal = Arr::get($order, 'shipping_total', 0);
159 - if ($shippingTotal != $oldShippingTotal) {
241 + if (array_key_exists('shipping_total', $requestData) && $shippingTotal != $oldShippingTotal) {
160 242 $diff = $shippingTotal - $oldShippingTotal;
161 243 if ($diff < 0) {
162 244 $requestData['total_amount'] = $updatedTotal - abs($diff);
163 245 } else {
@@ -246,8 +328,14 @@
246 328
247 329 }
248 330
249 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 + *
250 338 * @throws ValidationException
251 339 */
252 340 public function refundOrder(Request $request, $orderId)
253 341 {
@@ -258,11 +346,16 @@
258 346 'message' => __('Order can not be refunded.', 'fluent-cart')
259 347 ], 400);
260 348 }
261 349
262 - $refundInfo = $request->get('refund_info', []);
350 + $refundInfo = (array)$request->get('refund_info', []);
263 351
264 - $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, [
265 358 'transaction_id' => 'required',
266 359 'amount' => 'required',
267 360 ], [
268 361 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'),
@@ -268,10 +361,14 @@
268 361 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'),
269 362 'amount.required' => __('Refund amount is required', 'fluent-cart'),
270 363 ]);
271 364
365 + if ($validator->validate()->fails()) {
366 + return $this->sendError($validator->errors(), 422);
367 + }
368 +
272 369 $transaction = OrderTransaction::query()->where('order_id', $orderId)->findOrFail($refundInfo['transaction_id']);
273 - $refundAmount = Helper::toCent($refundInfo['amount']);
370 + $refundAmount = Helper::roundCent($refundInfo['amount']);
274 371
275 372 // refund on our end
276 373 $result = (new Refund())->processRefund($transaction, $refundAmount, $refundInfo);
277 374
@@ -637,16 +734,57 @@
637 734
638 735 return $data;
639 736 }
640 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 +
641 774 public function createCustom(Request $request, OrderItemHelper $orderItemHelper, Order $order)
642 775 {
643 776 try {
644 - return $orderItemHelper->processCustom(
777 + $orderItem = $orderItemHelper->processCustom(
645 778 $request->product,
646 779 $order->id
647 780 );
648 781
782 + return $this->sendSuccess([
783 + 'message' => __('Custom item has been added to the order!', 'fluent-cart'),
784 + 'order_item' => $orderItem
785 + ]);
786 +
649 787 } catch (\Exception $e) {
650 788 return $this->sendError([
651 789 'message' => $e->getMessage()
652 790 ], 423);
@@ -691,62 +829,109 @@
691 829
692 830
693 831 public function markAsPaid(Request $request, Order $order)
694 832 {
695 - $dueAmount = intval($order->total_amount - $order->total_paid);
833 + $db = Order::query()->getConnection();
834 + $db->beginTransaction();
696 835
697 - if ($dueAmount <= 0) {
698 - return $this->sendError([
699 - 'message' => __('Order has already been paid', 'fluent-cart')
700 - ], 423);
701 - }
836 + try {
837 + $locked = Order::query()
838 + ->where('id', $order->id)
839 + ->lockForUpdate()
840 + ->first();
702 841
703 - if (Arr::get($order, 'status') === 'canceled') {
704 - return $this->sendError([
705 - 'message' => __('Unable to mark paid for canceled order', 'fluent-cart')
706 - ], 423);
707 - }
842 + if (!$locked) {
843 + $db->rollBack();
844 + return $this->sendError([
845 + 'message' => __('Order not found', 'fluent-cart')
846 + ], 404);
847 + }
708 848
709 - // Reuse existing pending transaction without vendor_charge_id instead of creating a new one
710 - $transaction = $order->transactions
711 - ->where('status', Status::TRANSACTION_PENDING)
712 - ->filter(function ($t) {
713 - return empty($t->vendor_charge_id);
714 - })
715 - ->first();
849 + $dueAmount = intval($locked->total_amount - $locked->total_paid);
716 850
717 - $newTransactionData = [
718 - 'total' => $dueAmount,
719 - 'status' => Status::TRANSACTION_SUCCEEDED,
720 - 'payment_method' => sanitize_text_field($request->payment_method),
721 - 'vendor_charge_id' => sanitize_text_field($request->vendor_charge_id),
722 - 'payment_mode' => sanitize_text_field($order->mode),
723 - 'payment_method_type' => sanitize_text_field($request->payment_method),
724 - 'order_type' => sanitize_text_field($order->type),
725 - 'currency' => sanitize_text_field($order->currency),
726 - ];
851 + if ($dueAmount <= 0) {
852 + $db->rollBack();
853 + return $this->sendError([
854 + 'message' => __('Order has already been paid', 'fluent-cart')
855 + ], 423);
856 + }
727 857
728 - if ($transaction) {
729 - // Don't include transaction_type in the update — the existing value is always 'charge'
730 - // and overwriting it with the request value would break syncSubscriptionStates bill_count.
731 - $transaction->update($newTransactionData);
732 - } else {
733 - $transaction = OrderTransaction::query()->create(
734 - array_merge($newTransactionData, [
735 - 'order_id' => $order->id,
736 - 'transaction_type' => Status::TRANSACTION_TYPE_CHARGE,
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 + }
864 +
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();
878 +
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 + ];
889 +
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 + }
902 +
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');
913 +
914 + $locked->save();
915 +
916 + $db->commit();
917 + } catch (\Throwable $e) {
918 + $db->rollBack();
919 + throw $e;
739 920 }
740 921
741 - $note = sanitize_text_field($request->get('mark_paid_note', ''));
742 - if ($note) {
743 - $order->note = $note;
744 - $order->save();
922 + (new StatusHelper($locked))->syncOrderStatuses($transaction);
923 +
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 + );
745 932 }
746 933
747 - (new StatusHelper($order))->syncOrderStatuses($transaction);
748 -
749 934 return $this->response->sendSuccess([
750 935 'message' => __('Order has been marked as paid', 'fluent-cart')
751 936 ]);
752 937 }
@@ -772,11 +957,8 @@
772 957 'message' => __('Orders selection is required', 'fluent-cart')
773 958 ]);
774 959 }
775 960
776 - $orders = Order::query()->whereIn('id', $orderIds)->get();
777 -
778 -
779 961 if ($action == 'delete_orders') {
780 962
781 963 $isDeleted = OrderResource::bulkDeleteByOrderIds($orderIds);
782 964
@@ -808,17 +990,16 @@
808 990 // }
809 991
810 992
811 993 }
812 - if ($action == 'capture_payments') {
813 - foreach ($orders as $order) {
814 - $order->capturePayments();
815 - }
816 994
817 - return [
818 - 'message' => __('Selected payments has been successfully captured', 'fluent-cart')
819 - ];
820 - }
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.
821 1002
822 1003 return $this->sendError([
823 1004 'message' => __('Selected action is invalid', 'fluent-cart')
824 1005 ]);
@@ -919,10 +1100,25 @@
919 1100 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart')
920 1101 ]);
921 1102 }
922 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 +
923 1112 $transaction->updateStatus($newStatus);
924 - $order->updatePaymentStatus($newStatus);
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 + }
925 1121
926 1122 return [
927 1123 'transaction' => $transaction,
928 1124 'message' => __('Payment status has been successfully updated', 'fluent-cart')