| @@ -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') |