| @@ -5,14 +5,11 @@ | ||
| 5 | 5 | |
| 6 | 6 | use FluentCart\Api\Resource\CustomerResource; |
| 7 | 7 | use FluentCart\Api\Resource\OrderResource; |
| 8 | 8 | use FluentCart\Api\StoreSettings; |
| 9 | -use FluentCart\App\Events\Subscription\SubscriptionActivated; | |
| 10 | 9 | use FluentCart\App\Events\Order\OrderCreated; |
| 11 | 10 | use FluentCart\App\Events\Order\OrderDeleting; |
| 12 | 11 | use FluentCart\App\Events\Order\OrderDeleted; |
| 13 | -use FluentCart\App\Events\Order\OrderPaid; | |
| 14 | -use FluentCart\App\Events\Order\OrderStatusUpdated; | |
| 15 | 12 | use FluentCart\App\Events\Order\RenewalOrderDeleted; |
| 16 | 13 | use FluentCart\App\Helpers\CartHelper; |
| 17 | 14 | use FluentCart\App\Helpers\Helper; |
| 18 | 15 | use FluentCart\App\Helpers\OrderItemHelper; |
| @@ -39,12 +36,10 @@ | ||
| 39 | 36 | use FluentCart\App\Models\OrderDownloadPermission; |
| 40 | 37 | use FluentCart\App\Models\Subscription; |
| 41 | 38 | use FluentCart\App\Models\SubscriptionMeta; |
| 42 | 39 | use FluentCart\App\Services\Filter\OrderFilter; |
| 43 | -use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService; | |
| 44 | 40 | use FluentCart\App\Services\Payments\PaymentHelper; |
| 45 | 41 | use FluentCart\App\Services\Reminders\ReminderService; |
| 46 | -use FluentCart\App\Services\DateTime\DateTime; | |
| 47 | 42 | use FluentCart\App\Services\Payments\Refund; |
| 48 | 43 | use FluentCart\App\Services\URL; |
| 49 | 44 | use FluentCart\Framework\Http\Request\Request; |
| 50 | 45 | use FluentCart\Framework\Support\Arr; |
| @@ -79,14 +74,27 @@ | ||
| 79 | 74 | public function store(OrderRequest $request) |
| 80 | 75 | { |
| 81 | 76 | $data = $request->getSafe($request->sanitize()); |
| 82 | 77 | $type = 'payment'; |
| 83 | - $hasSubscription = static::hasSubscription(Arr::get($data, 'order_items', [])); | |
| 78 | + $orderItems = Arr::get($data, 'order_items', []); | |
| 79 | + | |
| 80 | + $variationPaymentTypes = static::getVariationPaymentTypes($orderItems); | |
| 81 | + | |
| 82 | + foreach ($orderItems as $item) { | |
| 83 | + $paymentTypeError = static::getPaymentTypeConflict($item, $variationPaymentTypes); | |
| 84 | + if ($paymentTypeError) { | |
| 85 | + return $this->sendError([ | |
| 86 | + 'message' => $paymentTypeError | |
| 87 | + ], 400); | |
| 88 | + } | |
| 89 | + } | |
| 90 | + | |
| 91 | + $hasSubscription = static::hasSubscription($orderItems); | |
| 84 | 92 | if ($hasSubscription) { |
| 85 | 93 | $type = 'subscription'; |
| 86 | 94 | // right now we don't support subscription with manual order |
| 87 | - $isSubscriptionAllowedInManualOrder = apply_filters('fluent_cart/order/is_subscription_allowed_in_manual_order', false, [ | |
| 88 | - 'order_items' => Arr::get($data, 'order_items', []) | |
| 95 | + $isSubscriptionAllowedInManualOrder = apply_filters('fluent_cart/order/is_subscription_allowed_in_manual_order', true, [ | |
| 96 | + 'order_items' => $orderItems | |
| 89 | 97 | ]); |
| 90 | 98 | |
| 91 | 99 | if (!$isSubscriptionAllowedInManualOrder) { |
| 92 | 100 | return $this->sendError([ |
| @@ -114,20 +122,86 @@ | ||
| 114 | 122 | 'uuid' => $order->uuid |
| 115 | 123 | ]); |
| 116 | 124 | } |
| 117 | 125 | |
| 118 | - | |
| 119 | 126 | public static function hasSubscription($orderItems): bool |
| 120 | 127 | { |
| 121 | - // check order items for subscription, payment_type == subscription | |
| 122 | 128 | foreach ($orderItems as $item) { |
| 123 | 129 | if (Arr::get($item, 'payment_type') == 'subscription' || Arr::get($item, 'other_info.payment_type') == 'subscription') { |
| 124 | 130 | return true; |
| 125 | 131 | } |
| 126 | 132 | } |
| 133 | + | |
| 127 | 134 | return false; |
| 128 | 135 | } |
| 129 | 136 | |
| 137 | + /** | |
| 138 | + * The variation row decides whether a line is recurring, so every label the payload | |
| 139 | + * carries has to agree with it. A recurring line must additionally be labelled in | |
| 140 | + * other_info: that is the only copy AdminOrderProcessor reads, and the interval and | |
| 141 | + * installment count travel beside it. | |
| 142 | + * | |
| 143 | + * @return string empty when the line is consistent, else the rejection message | |
| 144 | + */ | |
| 145 | + protected static function getPaymentTypeConflict($item, $variationPaymentTypes): string | |
| 146 | + { | |
| 147 | + $variationId = (int)Arr::get($item, 'object_id', 0); | |
| 148 | + | |
| 149 | + if (!isset($variationPaymentTypes[$variationId])) { | |
| 150 | + return ''; | |
| 151 | + } | |
| 152 | + | |
| 153 | + $isSubscriptionVariation = $variationPaymentTypes[$variationId] === 'subscription'; | |
| 154 | + | |
| 155 | + foreach (['payment_type', 'other_info.payment_type'] as $labelKey) { | |
| 156 | + $label = Arr::get($item, $labelKey); | |
| 157 | + if (is_null($label) || $label === '') { | |
| 158 | + continue; | |
| 159 | + } | |
| 160 | + | |
| 161 | + if (($label === 'subscription') !== $isSubscriptionVariation) { | |
| 162 | + return $isSubscriptionVariation | |
| 163 | + ? __('Subscription product cannot be placed as a one time item.', 'fluent-cart') | |
| 164 | + : __('One time product cannot be placed as a subscription item.', 'fluent-cart'); | |
| 165 | + } | |
| 166 | + } | |
| 167 | + | |
| 168 | + if ($isSubscriptionVariation && Arr::get($item, 'other_info.payment_type') !== 'subscription') { | |
| 169 | + return __('Subscription product must be placed as a subscription item.', 'fluent-cart'); | |
| 170 | + } | |
| 171 | + | |
| 172 | + return ''; | |
| 173 | + } | |
| 174 | + | |
| 175 | + /** | |
| 176 | + * @return array variation id => stored payment_type, for the lines that resolve | |
| 177 | + */ | |
| 178 | + protected static function getVariationPaymentTypes($orderItems): array | |
| 179 | + { | |
| 180 | + $variationIds = []; | |
| 181 | + foreach ($orderItems as $item) { | |
| 182 | + $variationId = (int)Arr::get($item, 'object_id', 0); | |
| 183 | + if ($variationId > 0) { | |
| 184 | + $variationIds[$variationId] = $variationId; | |
| 185 | + } | |
| 186 | + } | |
| 187 | + | |
| 188 | + if (!$variationIds) { | |
| 189 | + return []; | |
| 190 | + } | |
| 191 | + | |
| 192 | + $variations = ProductVariation::query() | |
| 193 | + ->whereIn('id', $variationIds) | |
| 194 | + ->get(['id', 'payment_type']); | |
| 195 | + | |
| 196 | + $paymentTypes = []; | |
| 197 | + foreach ($variations as $variation) { | |
| 198 | + $paymentTypes[(int)$variation->id] = $variation->payment_type; | |
| 199 | + } | |
| 200 | + | |
| 201 | + return $paymentTypes; | |
| 202 | + } | |
| 203 | + | |
| 130 | 204 | public function updateOrder(OrderRequest $request, $order_id) |
| 131 | 205 | { |
| 132 | 206 | $order = Order::query()->find($order_id); |
| 133 | 207 | |
| @@ -137,9 +211,12 @@ | ||
| 137 | 211 | ], 400); |
| 138 | 212 | } |
| 139 | 213 | |
| 140 | 214 | |
| 141 | - $requestData = $request->getSafe($request->sanitize()); | |
| 215 | + $requestData = array_intersect_key( | |
| 216 | + $request->getSafe($request->sanitize()), | |
| 217 | + $request->all() | |
| 218 | + ); | |
| 142 | 219 | |
| 143 | 220 | $totalPaid = Arr::get($request->all(), 'total_paid'); |
| 144 | 221 | $updatedTotal = Arr::get($requestData, 'total_amount'); |
| 145 | 222 | |
| @@ -160,9 +237,9 @@ | ||
| 160 | 237 | // if new shipping total is already adjusted in total amount, then no need to adjust again, right now not adjusted before |
| 161 | 238 | // ToDo: adjust changed shipping total in total amount prior to this |
| 162 | 239 | $shippingTotal = Arr::get($requestData, 'shipping_total', 0); |
| 163 | 240 | $oldShippingTotal = Arr::get($order, 'shipping_total', 0); |
| 164 | - if ($shippingTotal != $oldShippingTotal) { | |
| 241 | + if (array_key_exists('shipping_total', $requestData) && $shippingTotal != $oldShippingTotal) { | |
| 165 | 242 | $diff = $shippingTotal - $oldShippingTotal; |
| 166 | 243 | if ($diff < 0) { |
| 167 | 244 | $requestData['total_amount'] = $updatedTotal - abs($diff); |
| 168 | 245 | } else { |
| @@ -251,8 +328,14 @@ | ||
| 251 | 328 | |
| 252 | 329 | } |
| 253 | 330 | |
| 254 | 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 | + * | |
| 255 | 338 | * @throws ValidationException |
| 256 | 339 | */ |
| 257 | 340 | public function refundOrder(Request $request, $orderId) |
| 258 | 341 | { |
| @@ -263,11 +346,16 @@ | ||
| 263 | 346 | 'message' => __('Order can not be refunded.', 'fluent-cart') |
| 264 | 347 | ], 400); |
| 265 | 348 | } |
| 266 | 349 | |
| 267 | - $refundInfo = $request->get('refund_info', []); | |
| 350 | + $refundInfo = (array)$request->get('refund_info', []); | |
| 268 | 351 | |
| 269 | - $this->validate($refundInfo, [ | |
| 352 | + // $this->validate() reports failures only by exception, and outside a | |
| 353 | + // REST_REQUEST context the framework swallows that exception (no | |
| 354 | + // handle_exception listener) — execution would continue and crash on | |
| 355 | + // $refundInfo['transaction_id'] below. Fail closed: run the validator | |
| 356 | + // directly and return the per-field 422 payload in every context. | |
| 357 | + $validator = $this->app->validator->make($refundInfo, [ | |
| 270 | 358 | 'transaction_id' => 'required', |
| 271 | 359 | 'amount' => 'required', |
| 272 | 360 | ], [ |
| 273 | 361 | 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'), |
| @@ -273,10 +361,14 @@ | ||
| 273 | 361 | 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'), |
| 274 | 362 | 'amount.required' => __('Refund amount is required', 'fluent-cart'), |
| 275 | 363 | ]); |
| 276 | 364 | |
| 365 | + if ($validator->validate()->fails()) { | |
| 366 | + return $this->sendError($validator->errors(), 422); | |
| 367 | + } | |
| 368 | + | |
| 277 | 369 | $transaction = OrderTransaction::query()->where('order_id', $orderId)->findOrFail($refundInfo['transaction_id']); |
| 278 | - $refundAmount = Helper::toCent($refundInfo['amount']); | |
| 370 | + $refundAmount = Helper::roundCent($refundInfo['amount']); | |
| 279 | 371 | |
| 280 | 372 | // refund on our end |
| 281 | 373 | $result = (new Refund())->processRefund($transaction, $refundAmount, $refundInfo); |
| 282 | 374 | |
| @@ -642,16 +734,57 @@ | ||
| 642 | 734 | |
| 643 | 735 | return $data; |
| 644 | 736 | } |
| 645 | 737 | |
| 738 | + public function getTransactionDetails($orderId, $transactionId) | |
| 739 | + { | |
| 740 | + $orderId = (int)$orderId; | |
| 741 | + $transactionId = (int)$transactionId; | |
| 742 | + | |
| 743 | + $belongsToOrder = OrderTransaction::query() | |
| 744 | + ->where('id', $transactionId) | |
| 745 | + ->where('order_id', $orderId) | |
| 746 | + ->exists(); | |
| 747 | + | |
| 748 | + if (!$belongsToOrder) { | |
| 749 | + return $this->entityNotFoundError( | |
| 750 | + __('Transaction not found', 'fluent-cart'), | |
| 751 | + __('Back to orders', 'fluent-cart'), | |
| 752 | + '/orders' | |
| 753 | + ); | |
| 754 | + } | |
| 755 | + | |
| 756 | + $data = $this->getDetails($orderId); | |
| 757 | + | |
| 758 | + if (!is_array($data) || empty($data['order'])) { | |
| 759 | + return $data; | |
| 760 | + } | |
| 761 | + | |
| 762 | + // The path names one transaction, so the sibling rows on the same order | |
| 763 | + // are not part of this response. | |
| 764 | + $data['order']['transactions'] = array_values(array_filter( | |
| 765 | + (array)Arr::get($data, 'order.transactions', []), | |
| 766 | + function ($transaction) use ($transactionId) { | |
| 767 | + return (int)Arr::get($transaction, 'id') === $transactionId; | |
| 768 | + } | |
| 769 | + )); | |
| 770 | + | |
| 771 | + return $data; | |
| 772 | + } | |
| 773 | + | |
| 646 | 774 | public function createCustom(Request $request, OrderItemHelper $orderItemHelper, Order $order) |
| 647 | 775 | { |
| 648 | 776 | try { |
| 649 | - return $orderItemHelper->processCustom( | |
| 777 | + $orderItem = $orderItemHelper->processCustom( | |
| 650 | 778 | $request->product, |
| 651 | 779 | $order->id |
| 652 | 780 | ); |
| 653 | 781 | |
| 782 | + return $this->sendSuccess([ | |
| 783 | + 'message' => __('Custom item has been added to the order!', 'fluent-cart'), | |
| 784 | + 'order_item' => $orderItem | |
| 785 | + ]); | |
| 786 | + | |
| 654 | 787 | } catch (\Exception $e) { |
| 655 | 788 | return $this->sendError([ |
| 656 | 789 | 'message' => $e->getMessage() |
| 657 | 790 | ], 423); |
| @@ -696,97 +829,107 @@ | ||
| 696 | 829 | |
| 697 | 830 | |
| 698 | 831 | public function markAsPaid(Request $request, Order $order) |
| 699 | 832 | { |
| 700 | - $dueAmount = intval($order->total_amount - $order->total_paid); | |
| 833 | + $db = Order::query()->getConnection(); | |
| 834 | + $db->beginTransaction(); | |
| 701 | 835 | |
| 702 | - if ($dueAmount <= 0) { | |
| 703 | - return $this->sendError([ | |
| 704 | - 'message' => __('Order has already been paid', 'fluent-cart') | |
| 705 | - ], 423); | |
| 706 | - } | |
| 836 | + try { | |
| 837 | + $locked = Order::query() | |
| 838 | + ->where('id', $order->id) | |
| 839 | + ->lockForUpdate() | |
| 840 | + ->first(); | |
| 707 | 841 | |
| 708 | - if (Arr::get($order, 'status') === 'canceled') { | |
| 709 | - return $this->sendError([ | |
| 710 | - 'message' => __('Unable to mark paid for canceled order', 'fluent-cart') | |
| 711 | - ], 423); | |
| 712 | - } | |
| 842 | + if (!$locked) { | |
| 843 | + $db->rollBack(); | |
| 844 | + return $this->sendError([ | |
| 845 | + 'message' => __('Order not found', 'fluent-cart') | |
| 846 | + ], 404); | |
| 847 | + } | |
| 713 | 848 | |
| 714 | - $transaction = $order->transactions->where('status', Status::TRANSACTION_PENDING) | |
| 715 | - ->where('payment_method', 'offline_payment') | |
| 716 | - ->first(); | |
| 849 | + $dueAmount = intval($locked->total_amount - $locked->total_paid); | |
| 717 | 850 | |
| 718 | - $newTransactionData = [ | |
| 719 | - 'total' => $dueAmount, | |
| 720 | - 'status' => Status::TRANSACTION_SUCCEEDED, | |
| 721 | - 'payment_method' => sanitize_text_field($request->payment_method), | |
| 722 | - 'vendor_charge_id' => sanitize_text_field($request->vendor_charge_id), | |
| 723 | - 'payment_mode' => sanitize_text_field($order->mode), | |
| 724 | - 'payment_method_type' => sanitize_text_field($request->payment_method), | |
| 725 | - 'order_type' => sanitize_text_field($order->type), | |
| 726 | - 'transaction_type' => sanitize_text_field($request->transaction_type), | |
| 727 | - 'currency' => sanitize_text_field($order->currency), | |
| 728 | - ]; | |
| 851 | + if ($dueAmount <= 0) { | |
| 852 | + $db->rollBack(); | |
| 853 | + return $this->sendError([ | |
| 854 | + 'message' => __('Order has already been paid', 'fluent-cart') | |
| 855 | + ], 423); | |
| 856 | + } | |
| 729 | 857 | |
| 730 | - if ($transaction) { | |
| 731 | - $transaction->update($newTransactionData); | |
| 732 | - } else { | |
| 733 | - $transaction = OrderTransaction::query()->create( | |
| 734 | - array_merge($newTransactionData, [ | |
| 735 | - 'order_id' => $order->id | |
| 736 | - ]) | |
| 737 | - ); | |
| 738 | - } | |
| 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 | + } | |
| 739 | 864 | |
| 740 | - $order->note = sanitize_text_field($request->get('mark_paid_note', '')); | |
| 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(); | |
| 741 | 878 | |
| 742 | - $oldStatus = $order->status; | |
| 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 | + ]; | |
| 743 | 889 | |
| 744 | - if ($order->payment_status !== 'partially_refunded') { | |
| 745 | - $order->payment_status = Status::PAYMENT_PAID; | |
| 746 | - } | |
| 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 | + } | |
| 747 | 902 | |
| 748 | - $order->status = Status::ORDER_PROCESSING; | |
| 749 | - $order->total_paid = $order->total_amount; | |
| 750 | - $order->save(); | |
| 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'); | |
| 751 | 913 | |
| 752 | - $actionActivity = [ | |
| 753 | - 'title' => __('Order status updated', 'fluent-cart'), | |
| 754 | - 'content' => sprintf( | |
| 755 | - /* translators: 1: old status, 2: new status */ | |
| 756 | - __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), $oldStatus, $order->status) | |
| 757 | - ]; | |
| 914 | + $locked->save(); | |
| 758 | 915 | |
| 759 | - // dispatching events related to order status update and payment paid | |
| 760 | - (new OrderPaid($order, $order->customer, $transaction))->dispatch(); | |
| 761 | - | |
| 762 | - (new OrderStatusUpdated($order, $oldStatus, $order->status, true, $actionActivity, 'order_status'))->dispatch(); | |
| 763 | - | |
| 764 | - if ($order->type === 'subscription') { | |
| 765 | - $subscription = Subscription::query()->where('parent_order_id', $order->id)->first(); | |
| 766 | - if ($subscription) { | |
| 767 | - $oldSubStatus = $subscription->status; | |
| 768 | - $subscription = SubscriptionService::syncSubscriptionStates($subscription, ['status' => Status::SUBSCRIPTION_ACTIVE]); | |
| 769 | - if ($oldSubStatus !== Status::SUBSCRIPTION_ACTIVE && $subscription->status === Status::SUBSCRIPTION_ACTIVE) { | |
| 770 | - (new SubscriptionActivated($subscription, $order, $order->customer))->dispatch(); | |
| 771 | - } | |
| 772 | - } | |
| 916 | + $db->commit(); | |
| 917 | + } catch (\Throwable $e) { | |
| 918 | + $db->rollBack(); | |
| 919 | + throw $e; | |
| 773 | 920 | } |
| 774 | 921 | |
| 775 | - // if digital | |
| 776 | - if ($order->fulfillment_type == 'digital' && $order->status === Status::ORDER_PROCESSING) { | |
| 777 | - $order->status = Status::ORDER_COMPLETED; | |
| 778 | - $order->completed_at = DateTime::gmtNow(); | |
| 779 | - $order->save(); | |
| 922 | + (new StatusHelper($locked))->syncOrderStatuses($transaction); | |
| 780 | 923 | |
| 781 | - $actionActivity = [ | |
| 782 | - 'title' => __('Order status updated', 'fluent-cart'), | |
| 783 | - 'content' => sprintf( | |
| 784 | - /* translators: 1: old status, 2: new status */ | |
| 785 | - __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), Status::ORDER_PROCESSING, $order->status) | |
| 786 | - ]; | |
| 787 | - | |
| 788 | - (new OrderStatusUpdated($order, Status::ORDER_PROCESSING, $order->status, true, $actionActivity, 'order_status'))->dispatch(); | |
| 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 | + ); | |
| 789 | 932 | } |
| 790 | 933 | |
| 791 | 934 | return $this->response->sendSuccess([ |
| 792 | 935 | 'message' => __('Order has been marked as paid', 'fluent-cart') |
| @@ -814,11 +957,8 @@ | ||
| 814 | 957 | 'message' => __('Orders selection is required', 'fluent-cart') |
| 815 | 958 | ]); |
| 816 | 959 | } |
| 817 | 960 | |
| 818 | - $orders = Order::query()->whereIn('id', $orderIds)->get(); | |
| 819 | - | |
| 820 | - | |
| 821 | 961 | if ($action == 'delete_orders') { |
| 822 | 962 | |
| 823 | 963 | $isDeleted = OrderResource::bulkDeleteByOrderIds($orderIds); |
| 824 | 964 | |
| @@ -850,17 +990,16 @@ | ||
| 850 | 990 | // } |
| 851 | 991 | |
| 852 | 992 | |
| 853 | 993 | } |
| 854 | - if ($action == 'capture_payments') { | |
| 855 | - foreach ($orders as $order) { | |
| 856 | - $order->capturePayments(); | |
| 857 | - } | |
| 858 | 994 | |
| 859 | - return [ | |
| 860 | - 'message' => __('Selected payments has been successfully captured', 'fluent-cart') | |
| 861 | - ]; | |
| 862 | - } | |
| 995 | + // The capture_payments branch was removed: it called | |
| 996 | + // $order->capturePayments(), a method that has never existed anywhere | |
| 997 | + // in the codebase, so the action fataled on the first order (audit | |
| 998 | + // item #43). No UI sends it — the orders bulk bar submits | |
| 999 | + // delete_test_orders only. Bulk payment capture, if wanted, is a | |
| 1000 | + // gateway feature to design (authorize/capture per gateway), not a | |
| 1001 | + // branch to resurrect as-is. | |
| 863 | 1002 | |
| 864 | 1003 | return $this->sendError([ |
| 865 | 1004 | 'message' => __('Selected action is invalid', 'fluent-cart') |
| 866 | 1005 | ]); |
| @@ -961,15 +1100,54 @@ | ||
| 961 | 1100 | 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart') |
| 962 | 1101 | ]); |
| 963 | 1102 | } |
| 964 | 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 | + | |
| 965 | 1112 | $transaction->updateStatus($newStatus); |
| 966 | - $order->updatePaymentStatus($newStatus); | |
| 967 | 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 | + } | |
| 1121 | + | |
| 968 | 1122 | return [ |
| 969 | 1123 | 'transaction' => $transaction, |
| 970 | 1124 | 'message' => __('Payment status has been successfully updated', 'fluent-cart') |
| 971 | 1125 | ]; |
| 1126 | + } | |
| 1127 | + | |
| 1128 | + public function syncPendingTransaction(Request $request, $order, OrderTransaction $transaction) | |
| 1129 | + { | |
| 1130 | + $order = Order::query()->find($order); | |
| 1131 | + | |
| 1132 | + if (!$order || $transaction->order_id != $order->id) { | |
| 1133 | + return $this->sendError([ | |
| 1134 | + 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart') | |
| 1135 | + ]); | |
| 1136 | + } | |
| 1137 | + | |
| 1138 | + $result = $transaction->syncPendingTransaction(); | |
| 1139 | + | |
| 1140 | + if (is_wp_error($result)) { | |
| 1141 | + return $this->sendError([ | |
| 1142 | + 'message' => $result->get_error_message() | |
| 1143 | + ]); | |
| 1144 | + } | |
| 1145 | + | |
| 1146 | + return $this->sendSuccess([ | |
| 1147 | + 'message' => __('Transaction has been synced from the payment gateway successfully!', 'fluent-cart'), | |
| 1148 | + 'transaction' => $result | |
| 1149 | + ]); | |
| 972 | 1150 | } |
| 973 | 1151 | |
| 974 | 1152 | public function getStats($orderUuid): \WP_REST_Response |
| 975 | 1153 | { |