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 +120 -49 1.6.1 → 1.7.1 View file →
@@ -211,9 +211,12 @@
211 211 ], 400);
212 212 }
213 213
214 214
215 - $requestData = $request->getSafe($request->sanitize());
215 + $requestData = array_intersect_key(
216 + $request->getSafe($request->sanitize()),
217 + $request->all()
218 + );
216 219
217 220 $totalPaid = Arr::get($request->all(), 'total_paid');
218 221 $updatedTotal = Arr::get($requestData, 'total_amount');
219 222
@@ -234,9 +237,9 @@
234 237 // if new shipping total is already adjusted in total amount, then no need to adjust again, right now not adjusted before
235 238 // ToDo: adjust changed shipping total in total amount prior to this
236 239 $shippingTotal = Arr::get($requestData, 'shipping_total', 0);
237 240 $oldShippingTotal = Arr::get($order, 'shipping_total', 0);
238 - if ($shippingTotal != $oldShippingTotal) {
241 + if (array_key_exists('shipping_total', $requestData) && $shippingTotal != $oldShippingTotal) {
239 242 $diff = $shippingTotal - $oldShippingTotal;
240 243 if ($diff < 0) {
241 244 $requestData['total_amount'] = $updatedTotal - abs($diff);
242 245 } else {
@@ -325,8 +328,14 @@
325 328
326 329 }
327 330
328 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 + *
329 338 * @throws ValidationException
330 339 */
331 340 public function refundOrder(Request $request, $orderId)
332 341 {
@@ -357,9 +366,9 @@
357 366 return $this->sendError($validator->errors(), 422);
358 367 }
359 368
360 369 $transaction = OrderTransaction::query()->where('order_id', $orderId)->findOrFail($refundInfo['transaction_id']);
361 - $refundAmount = Helper::toCent($refundInfo['amount']);
370 + $refundAmount = Helper::roundCent($refundInfo['amount']);
362 371
363 372 // refund on our end
364 373 $result = (new Refund())->processRefund($transaction, $refundAmount, $refundInfo);
365 374
@@ -820,62 +829,109 @@
820 829
821 830
822 831 public function markAsPaid(Request $request, Order $order)
823 832 {
824 - $dueAmount = intval($order->total_amount - $order->total_paid);
833 + $db = Order::query()->getConnection();
834 + $db->beginTransaction();
825 835
826 - if ($dueAmount <= 0) {
827 - return $this->sendError([
828 - 'message' => __('Order has already been paid', 'fluent-cart')
829 - ], 423);
830 - }
836 + try {
837 + $locked = Order::query()
838 + ->where('id', $order->id)
839 + ->lockForUpdate()
840 + ->first();
831 841
832 - if (Arr::get($order, 'status') === 'canceled') {
833 - return $this->sendError([
834 - 'message' => __('Unable to mark paid for canceled order', 'fluent-cart')
835 - ], 423);
836 - }
842 + if (!$locked) {
843 + $db->rollBack();
844 + return $this->sendError([
845 + 'message' => __('Order not found', 'fluent-cart')
846 + ], 404);
847 + }
837 848
838 - // Reuse existing pending transaction without vendor_charge_id instead of creating a new one
839 - $transaction = $order->transactions
840 - ->where('status', Status::TRANSACTION_PENDING)
841 - ->filter(function ($t) {
842 - return empty($t->vendor_charge_id);
843 - })
844 - ->first();
849 + $dueAmount = intval($locked->total_amount - $locked->total_paid);
845 850
846 - $newTransactionData = [
847 - 'total' => $dueAmount,
848 - 'status' => Status::TRANSACTION_SUCCEEDED,
849 - 'payment_method' => sanitize_text_field($request->payment_method),
850 - 'vendor_charge_id' => sanitize_text_field($request->vendor_charge_id),
851 - 'payment_mode' => sanitize_text_field($order->mode),
852 - 'payment_method_type' => sanitize_text_field($request->payment_method),
853 - 'order_type' => sanitize_text_field($order->type),
854 - 'currency' => sanitize_text_field($order->currency),
855 - ];
851 + if ($dueAmount <= 0) {
852 + $db->rollBack();
853 + return $this->sendError([
854 + 'message' => __('Order has already been paid', 'fluent-cart')
855 + ], 423);
856 + }
856 857
857 - if ($transaction) {
858 - // Don't include transaction_type in the update — the existing value is always 'charge'
859 - // and overwriting it with the request value would break syncSubscriptionStates bill_count.
860 - $transaction->update($newTransactionData);
861 - } else {
862 - $transaction = OrderTransaction::query()->create(
863 - array_merge($newTransactionData, [
864 - 'order_id' => $order->id,
865 - 'transaction_type' => Status::TRANSACTION_TYPE_CHARGE,
866 - ])
867 - );
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;
868 920 }
869 921
870 - $note = sanitize_text_field($request->get('mark_paid_note', ''));
871 - if ($note) {
872 - $order->note = $note;
873 - $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 + );
874 932 }
875 933
876 - (new StatusHelper($order))->syncOrderStatuses($transaction);
877 -
878 934 return $this->response->sendSuccess([
879 935 'message' => __('Order has been marked as paid', 'fluent-cart')
880 936 ]);
881 937 }
@@ -1044,10 +1100,25 @@
1044 1100 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart')
1045 1101 ]);
1046 1102 }
1047 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 +
1048 1112 $transaction->updateStatus($newStatus);
1049 - $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 + }
1050 1121
1051 1122 return [
1052 1123 'transaction' => $transaction,
1053 1124 'message' => __('Payment status has been successfully updated', 'fluent-cart')