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 +102 -46 1.6.3 → 1.6.5 View file →
@@ -826,61 +826,102 @@
826 826
827 827
828 828 public function markAsPaid(Request $request, Order $order)
829 829 {
830 - $dueAmount = intval($order->total_amount - $order->total_paid);
830 + $db = Order::query()->getConnection();
831 + $db->beginTransaction();
831 832
832 - if ($dueAmount <= 0) {
833 - return $this->sendError([
834 - 'message' => __('Order has already been paid', 'fluent-cart')
835 - ], 423);
836 - }
833 + try {
834 + $locked = Order::query()
835 + ->where('id', $order->id)
836 + ->lockForUpdate()
837 + ->first();
837 838
838 - if (Arr::get($order, 'status') === 'canceled') {
839 - return $this->sendError([
840 - 'message' => __('Unable to mark paid for canceled order', 'fluent-cart')
841 - ], 423);
842 - }
839 + if (!$locked) {
840 + $db->rollBack();
841 + return $this->sendError([
842 + 'message' => __('Order not found', 'fluent-cart')
843 + ], 404);
844 + }
843 845
844 - // Reuse existing pending transaction without vendor_charge_id instead of creating a new one
845 - $transaction = $order->transactions
846 - ->where('status', Status::TRANSACTION_PENDING)
847 - ->filter(function ($t) {
848 - return empty($t->vendor_charge_id);
849 - })
850 - ->first();
846 + $dueAmount = intval($locked->total_amount - $locked->total_paid);
851 847
852 - $newTransactionData = [
853 - 'total' => $dueAmount,
854 - 'status' => Status::TRANSACTION_SUCCEEDED,
855 - 'payment_method' => sanitize_text_field($request->payment_method),
856 - 'vendor_charge_id' => sanitize_text_field($request->vendor_charge_id),
857 - 'payment_mode' => sanitize_text_field($order->mode),
858 - 'payment_method_type' => sanitize_text_field($request->payment_method),
859 - 'order_type' => sanitize_text_field($order->type),
860 - 'currency' => sanitize_text_field($order->currency),
861 - ];
848 + if ($dueAmount <= 0) {
849 + $db->rollBack();
850 + return $this->sendError([
851 + 'message' => __('Order has already been paid', 'fluent-cart')
852 + ], 423);
853 + }
862 854
863 - if ($transaction) {
864 - // Don't include transaction_type in the update — the existing value is always 'charge'
865 - // and overwriting it with the request value would break syncSubscriptionStates bill_count.
866 - $transaction->update($newTransactionData);
867 - } else {
868 - $transaction = OrderTransaction::query()->create(
869 - array_merge($newTransactionData, [
870 - 'order_id' => $order->id,
871 - 'transaction_type' => Status::TRANSACTION_TYPE_CHARGE,
872 - ])
873 - );
874 - }
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 + }
875 861
876 - $note = sanitize_text_field($request->get('mark_paid_note', ''));
877 - if ($note) {
878 - $order->note = $note;
879 - $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;
880 921 }
881 922
882 - (new StatusHelper($order))->syncOrderStatuses($transaction);
923 + (new StatusHelper($locked))->syncOrderStatuses($transaction);
883 924
884 925 return $this->response->sendSuccess([
885 926 'message' => __('Order has been marked as paid', 'fluent-cart')
886 927 ]);
@@ -1050,10 +1091,25 @@
1050 1091 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart')
1051 1092 ]);
1052 1093 }
1053 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 +
1054 1103 $transaction->updateStatus($newStatus);
1055 - $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 + }
1056 1112
1057 1113 return [
1058 1114 'transaction' => $transaction,
1059 1115 'message' => __('Payment status has been successfully updated', 'fluent-cart')