| @@ -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 { |
| @@ -826,62 +829,109 @@ | ||
| 826 | 829 | |
| 827 | 830 | |
| 828 | 831 | public function markAsPaid(Request $request, Order $order) |
| 829 | 832 | { |
| 830 | - $dueAmount = intval($order->total_amount - $order->total_paid); | |
| 833 | + $db = Order::query()->getConnection(); | |
| 834 | + $db->beginTransaction(); | |
| 831 | 835 | |
| 832 | - if ($dueAmount <= 0) { | |
| 833 | - return $this->sendError([ | |
| 834 | - 'message' => __('Order has already been paid', 'fluent-cart') | |
| 835 | - ], 423); | |
| 836 | - } | |
| 836 | + try { | |
| 837 | + $locked = Order::query() | |
| 838 | + ->where('id', $order->id) | |
| 839 | + ->lockForUpdate() | |
| 840 | + ->first(); | |
| 837 | 841 | |
| 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 | - } | |
| 842 | + if (!$locked) { | |
| 843 | + $db->rollBack(); | |
| 844 | + return $this->sendError([ | |
| 845 | + 'message' => __('Order not found', 'fluent-cart') | |
| 846 | + ], 404); | |
| 847 | + } | |
| 843 | 848 | |
| 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(); | |
| 849 | + $dueAmount = intval($locked->total_amount - $locked->total_paid); | |
| 851 | 850 | |
| 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 | - ]; | |
| 851 | + if ($dueAmount <= 0) { | |
| 852 | + $db->rollBack(); | |
| 853 | + return $this->sendError([ | |
| 854 | + 'message' => __('Order has already been paid', 'fluent-cart') | |
| 855 | + ], 423); | |
| 856 | + } | |
| 862 | 857 | |
| 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 | - ); | |
| 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; | |
| 874 | 920 | } |
| 875 | 921 | |
| 876 | - $note = sanitize_text_field($request->get('mark_paid_note', '')); | |
| 877 | - if ($note) { | |
| 878 | - $order->note = $note; | |
| 879 | - $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 | + ); | |
| 880 | 932 | } |
| 881 | 933 | |
| 882 | - (new StatusHelper($order))->syncOrderStatuses($transaction); | |
| 883 | - | |
| 884 | 934 | return $this->response->sendSuccess([ |
| 885 | 935 | 'message' => __('Order has been marked as paid', 'fluent-cart') |
| 886 | 936 | ]); |
| 887 | 937 | } |
| @@ -1050,10 +1100,25 @@ | ||
| 1050 | 1100 | 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart') |
| 1051 | 1101 | ]); |
| 1052 | 1102 | } |
| 1053 | 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 | + | |
| 1054 | 1112 | $transaction->updateStatus($newStatus); |
| 1055 | - $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 | + } | |
| 1056 | 1121 | |
| 1057 | 1122 | return [ |
| 1058 | 1123 | 'transaction' => $transaction, |
| 1059 | 1124 | 'message' => __('Payment status has been successfully updated', 'fluent-cart') |