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 +252 -65 1.6.0 → 1.6.5 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
@@ -246,8 +325,14 @@
246 325
247 326 }
248 327
249 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 + *
250 335 * @throws ValidationException
251 336 */
252 337 public function refundOrder(Request $request, $orderId)
253 338 {
@@ -258,11 +343,16 @@
258 343 'message' => __('Order can not be refunded.', 'fluent-cart')
259 344 ], 400);
260 345 }
261 346
262 - $refundInfo = $request->get('refund_info', []);
347 + $refundInfo = (array)$request->get('refund_info', []);
263 348
264 - $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, [
265 355 'transaction_id' => 'required',
266 356 'amount' => 'required',
267 357 ], [
268 358 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'),
@@ -268,10 +358,14 @@
268 358 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'),
269 359 'amount.required' => __('Refund amount is required', 'fluent-cart'),
270 360 ]);
271 361
362 + if ($validator->validate()->fails()) {
363 + return $this->sendError($validator->errors(), 422);
364 + }
365 +
272 366 $transaction = OrderTransaction::query()->where('order_id', $orderId)->findOrFail($refundInfo['transaction_id']);
273 - $refundAmount = Helper::toCent($refundInfo['amount']);
367 + $refundAmount = Helper::roundCent($refundInfo['amount']);
274 368
275 369 // refund on our end
276 370 $result = (new Refund())->processRefund($transaction, $refundAmount, $refundInfo);
277 371
@@ -637,16 +731,57 @@
637 731
638 732 return $data;
639 733 }
640 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 +
641 771 public function createCustom(Request $request, OrderItemHelper $orderItemHelper, Order $order)
642 772 {
643 773 try {
644 - return $orderItemHelper->processCustom(
774 + $orderItem = $orderItemHelper->processCustom(
645 775 $request->product,
646 776 $order->id
647 777 );
648 778
779 + return $this->sendSuccess([
780 + 'message' => __('Custom item has been added to the order!', 'fluent-cart'),
781 + 'order_item' => $orderItem
782 + ]);
783 +
649 784 } catch (\Exception $e) {
650 785 return $this->sendError([
651 786 'message' => $e->getMessage()
652 787 ], 423);
@@ -691,61 +826,102 @@
691 826
692 827
693 828 public function markAsPaid(Request $request, Order $order)
694 829 {
695 - $dueAmount = intval($order->total_amount - $order->total_paid);
830 + $db = Order::query()->getConnection();
831 + $db->beginTransaction();
696 832
697 - if ($dueAmount <= 0) {
698 - return $this->sendError([
699 - 'message' => __('Order has already been paid', 'fluent-cart')
700 - ], 423);
701 - }
833 + try {
834 + $locked = Order::query()
835 + ->where('id', $order->id)
836 + ->lockForUpdate()
837 + ->first();
702 838
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 - }
839 + if (!$locked) {
840 + $db->rollBack();
841 + return $this->sendError([
842 + 'message' => __('Order not found', 'fluent-cart')
843 + ], 404);
844 + }
708 845
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();
846 + $dueAmount = intval($locked->total_amount - $locked->total_paid);
716 847
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 - ];
848 + if ($dueAmount <= 0) {
849 + $db->rollBack();
850 + return $this->sendError([
851 + 'message' => __('Order has already been paid', 'fluent-cart')
852 + ], 423);
853 + }
727 854
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 - );
739 - }
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 + }
740 861
741 - $note = sanitize_text_field($request->get('mark_paid_note', ''));
742 - if ($note) {
743 - $order->note = $note;
744 - $order->save();
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();
875 +
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 + ];
886 +
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 + }
899 +
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');
910 +
911 + $note = sanitize_text_field($request->get('mark_paid_note', ''));
912 + if ($note) {
913 + $locked->note = $note;
914 + }
915 + $locked->save();
916 +
917 + $db->commit();
918 + } catch (\Throwable $e) {
919 + $db->rollBack();
920 + throw $e;
745 921 }
746 922
747 - (new StatusHelper($order))->syncOrderStatuses($transaction);
923 + (new StatusHelper($locked))->syncOrderStatuses($transaction);
748 924
749 925 return $this->response->sendSuccess([
750 926 'message' => __('Order has been marked as paid', 'fluent-cart')
751 927 ]);
@@ -772,11 +948,8 @@
772 948 'message' => __('Orders selection is required', 'fluent-cart')
773 949 ]);
774 950 }
775 951
776 - $orders = Order::query()->whereIn('id', $orderIds)->get();
777 -
778 -
779 952 if ($action == 'delete_orders') {
780 953
781 954 $isDeleted = OrderResource::bulkDeleteByOrderIds($orderIds);
782 955
@@ -808,17 +981,16 @@
808 981 // }
809 982
810 983
811 984 }
812 - if ($action == 'capture_payments') {
813 - foreach ($orders as $order) {
814 - $order->capturePayments();
815 - }
816 985
817 - return [
818 - 'message' => __('Selected payments has been successfully captured', 'fluent-cart')
819 - ];
820 - }
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.
821 993
822 994 return $this->sendError([
823 995 'message' => __('Selected action is invalid', 'fluent-cart')
824 996 ]);
@@ -919,10 +1091,25 @@
919 1091 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart')
920 1092 ]);
921 1093 }
922 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 +
923 1103 $transaction->updateStatus($newStatus);
924 - $order->updatePaymentStatus($newStatus);
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 + }
925 1112
926 1113 return [
927 1114 'transaction' => $transaction,
928 1115 'message' => __('Payment status has been successfully updated', 'fluent-cart')