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 +109 -47 1.6.1 → 1.6.5 View file →
@@ -325,8 +325,14 @@
325 325
326 326 }
327 327
328 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 + *
329 335 * @throws ValidationException
330 336 */
331 337 public function refundOrder(Request $request, $orderId)
332 338 {
@@ -357,9 +363,9 @@
357 363 return $this->sendError($validator->errors(), 422);
358 364 }
359 365
360 366 $transaction = OrderTransaction::query()->where('order_id', $orderId)->findOrFail($refundInfo['transaction_id']);
361 - $refundAmount = Helper::toCent($refundInfo['amount']);
367 + $refundAmount = Helper::roundCent($refundInfo['amount']);
362 368
363 369 // refund on our end
364 370 $result = (new Refund())->processRefund($transaction, $refundAmount, $refundInfo);
365 371
@@ -820,61 +826,102 @@
820 826
821 827
822 828 public function markAsPaid(Request $request, Order $order)
823 829 {
824 - $dueAmount = intval($order->total_amount - $order->total_paid);
830 + $db = Order::query()->getConnection();
831 + $db->beginTransaction();
825 832
826 - if ($dueAmount <= 0) {
827 - return $this->sendError([
828 - 'message' => __('Order has already been paid', 'fluent-cart')
829 - ], 423);
830 - }
833 + try {
834 + $locked = Order::query()
835 + ->where('id', $order->id)
836 + ->lockForUpdate()
837 + ->first();
831 838
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 - }
839 + if (!$locked) {
840 + $db->rollBack();
841 + return $this->sendError([
842 + 'message' => __('Order not found', 'fluent-cart')
843 + ], 404);
844 + }
837 845
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();
846 + $dueAmount = intval($locked->total_amount - $locked->total_paid);
845 847
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 - ];
848 + if ($dueAmount <= 0) {
849 + $db->rollBack();
850 + return $this->sendError([
851 + 'message' => __('Order has already been paid', 'fluent-cart')
852 + ], 423);
853 + }
856 854
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 - );
868 - }
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 + }
869 861
870 - $note = sanitize_text_field($request->get('mark_paid_note', ''));
871 - if ($note) {
872 - $order->note = $note;
873 - $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;
874 921 }
875 922
876 - (new StatusHelper($order))->syncOrderStatuses($transaction);
923 + (new StatusHelper($locked))->syncOrderStatuses($transaction);
877 924
878 925 return $this->response->sendSuccess([
879 926 'message' => __('Order has been marked as paid', 'fluent-cart')
880 927 ]);
@@ -1044,10 +1091,25 @@
1044 1091 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart')
1045 1092 ]);
1046 1093 }
1047 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 +
1048 1103 $transaction->updateStatus($newStatus);
1049 - $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 + }
1050 1112
1051 1113 return [
1052 1114 'transaction' => $transaction,
1053 1115 'message' => __('Payment status has been successfully updated', 'fluent-cart')