| @@ -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 | |
| @@ -251,8 +325,14 @@ | ||
| 251 | 325 | |
| 252 | 326 | } |
| 253 | 327 | |
| 254 | 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 | + * | |
| 255 | 335 | * @throws ValidationException |
| 256 | 336 | */ |
| 257 | 337 | public function refundOrder(Request $request, $orderId) |
| 258 | 338 | { |
| @@ -263,11 +343,16 @@ | ||
| 263 | 343 | 'message' => __('Order can not be refunded.', 'fluent-cart') |
| 264 | 344 | ], 400); |
| 265 | 345 | } |
| 266 | 346 | |
| 267 | - $refundInfo = $request->get('refund_info', []); | |
| 347 | + $refundInfo = (array)$request->get('refund_info', []); | |
| 268 | 348 | |
| 269 | - $this->validate($refundInfo, [ | |
| 349 | + // $this->validate() reports failures only by exception, and outside a | |
| 350 | + // REST_REQUEST context the framework swallows that exception (no | |
| 351 | + // handle_exception listener) — execution would continue and crash on | |
| 352 | + // $refundInfo['transaction_id'] below. Fail closed: run the validator | |
| 353 | + // directly and return the per-field 422 payload in every context. | |
| 354 | + $validator = $this->app->validator->make($refundInfo, [ | |
| 270 | 355 | 'transaction_id' => 'required', |
| 271 | 356 | 'amount' => 'required', |
| 272 | 357 | ], [ |
| 273 | 358 | 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'), |
| @@ -273,10 +358,14 @@ | ||
| 273 | 358 | 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'), |
| 274 | 359 | 'amount.required' => __('Refund amount is required', 'fluent-cart'), |
| 275 | 360 | ]); |
| 276 | 361 | |
| 362 | + if ($validator->validate()->fails()) { | |
| 363 | + return $this->sendError($validator->errors(), 422); | |
| 364 | + } | |
| 365 | + | |
| 277 | 366 | $transaction = OrderTransaction::query()->where('order_id', $orderId)->findOrFail($refundInfo['transaction_id']); |
| 278 | - $refundAmount = Helper::toCent($refundInfo['amount']); | |
| 367 | + $refundAmount = Helper::roundCent($refundInfo['amount']); | |
| 279 | 368 | |
| 280 | 369 | // refund on our end |
| 281 | 370 | $result = (new Refund())->processRefund($transaction, $refundAmount, $refundInfo); |
| 282 | 371 | |
| @@ -642,16 +731,57 @@ | ||
| 642 | 731 | |
| 643 | 732 | return $data; |
| 644 | 733 | } |
| 645 | 734 | |
| 735 | + public function getTransactionDetails($orderId, $transactionId) | |
| 736 | + { | |
| 737 | + $orderId = (int)$orderId; | |
| 738 | + $transactionId = (int)$transactionId; | |
| 739 | + | |
| 740 | + $belongsToOrder = OrderTransaction::query() | |
| 741 | + ->where('id', $transactionId) | |
| 742 | + ->where('order_id', $orderId) | |
| 743 | + ->exists(); | |
| 744 | + | |
| 745 | + if (!$belongsToOrder) { | |
| 746 | + return $this->entityNotFoundError( | |
| 747 | + __('Transaction not found', 'fluent-cart'), | |
| 748 | + __('Back to orders', 'fluent-cart'), | |
| 749 | + '/orders' | |
| 750 | + ); | |
| 751 | + } | |
| 752 | + | |
| 753 | + $data = $this->getDetails($orderId); | |
| 754 | + | |
| 755 | + if (!is_array($data) || empty($data['order'])) { | |
| 756 | + return $data; | |
| 757 | + } | |
| 758 | + | |
| 759 | + // The path names one transaction, so the sibling rows on the same order | |
| 760 | + // are not part of this response. | |
| 761 | + $data['order']['transactions'] = array_values(array_filter( | |
| 762 | + (array)Arr::get($data, 'order.transactions', []), | |
| 763 | + function ($transaction) use ($transactionId) { | |
| 764 | + return (int)Arr::get($transaction, 'id') === $transactionId; | |
| 765 | + } | |
| 766 | + )); | |
| 767 | + | |
| 768 | + return $data; | |
| 769 | + } | |
| 770 | + | |
| 646 | 771 | public function createCustom(Request $request, OrderItemHelper $orderItemHelper, Order $order) |
| 647 | 772 | { |
| 648 | 773 | try { |
| 649 | - return $orderItemHelper->processCustom( | |
| 774 | + $orderItem = $orderItemHelper->processCustom( | |
| 650 | 775 | $request->product, |
| 651 | 776 | $order->id |
| 652 | 777 | ); |
| 653 | 778 | |
| 779 | + return $this->sendSuccess([ | |
| 780 | + 'message' => __('Custom item has been added to the order!', 'fluent-cart'), | |
| 781 | + 'order_item' => $orderItem | |
| 782 | + ]); | |
| 783 | + | |
| 654 | 784 | } catch (\Exception $e) { |
| 655 | 785 | return $this->sendError([ |
| 656 | 786 | 'message' => $e->getMessage() |
| 657 | 787 | ], 423); |
| @@ -696,99 +826,103 @@ | ||
| 696 | 826 | |
| 697 | 827 | |
| 698 | 828 | public function markAsPaid(Request $request, Order $order) |
| 699 | 829 | { |
| 700 | - $dueAmount = intval($order->total_amount - $order->total_paid); | |
| 830 | + $db = Order::query()->getConnection(); | |
| 831 | + $db->beginTransaction(); | |
| 701 | 832 | |
| 702 | - if ($dueAmount <= 0) { | |
| 703 | - return $this->sendError([ | |
| 704 | - 'message' => __('Order has already been paid', 'fluent-cart') | |
| 705 | - ], 423); | |
| 706 | - } | |
| 833 | + try { | |
| 834 | + $locked = Order::query() | |
| 835 | + ->where('id', $order->id) | |
| 836 | + ->lockForUpdate() | |
| 837 | + ->first(); | |
| 707 | 838 | |
| 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 | - } | |
| 839 | + if (!$locked) { | |
| 840 | + $db->rollBack(); | |
| 841 | + return $this->sendError([ | |
| 842 | + 'message' => __('Order not found', 'fluent-cart') | |
| 843 | + ], 404); | |
| 844 | + } | |
| 713 | 845 | |
| 714 | - $transaction = $order->transactions->where('status', Status::TRANSACTION_PENDING) | |
| 715 | - ->where('payment_method', 'offline_payment') | |
| 716 | - ->first(); | |
| 846 | + $dueAmount = intval($locked->total_amount - $locked->total_paid); | |
| 717 | 847 | |
| 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 | - ]; | |
| 848 | + if ($dueAmount <= 0) { | |
| 849 | + $db->rollBack(); | |
| 850 | + return $this->sendError([ | |
| 851 | + 'message' => __('Order has already been paid', 'fluent-cart') | |
| 852 | + ], 423); | |
| 853 | + } | |
| 729 | 854 | |
| 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 | - } | |
| 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 | + } | |
| 739 | 861 | |
| 740 | - $order->note = sanitize_text_field($request->get('mark_paid_note', '')); | |
| 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(); | |
| 741 | 875 | |
| 742 | - $oldStatus = $order->status; | |
| 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 | + ]; | |
| 743 | 886 | |
| 744 | - if ($order->payment_status !== 'partially_refunded') { | |
| 745 | - $order->payment_status = Status::PAYMENT_PAID; | |
| 746 | - } | |
| 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 | + } | |
| 747 | 899 | |
| 748 | - $order->status = Status::ORDER_PROCESSING; | |
| 749 | - $order->total_paid = $order->total_amount; | |
| 750 | - $order->save(); | |
| 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'); | |
| 751 | 910 | |
| 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 | - ]; | |
| 911 | + $note = sanitize_text_field($request->get('mark_paid_note', '')); | |
| 912 | + if ($note) { | |
| 913 | + $locked->note = $note; | |
| 914 | + } | |
| 915 | + $locked->save(); | |
| 758 | 916 | |
| 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 | - } | |
| 917 | + $db->commit(); | |
| 918 | + } catch (\Throwable $e) { | |
| 919 | + $db->rollBack(); | |
| 920 | + throw $e; | |
| 773 | 921 | } |
| 774 | 922 | |
| 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(); | |
| 923 | + (new StatusHelper($locked))->syncOrderStatuses($transaction); | |
| 780 | 924 | |
| 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(); | |
| 789 | - } | |
| 790 | - | |
| 791 | 925 | return $this->response->sendSuccess([ |
| 792 | 926 | 'message' => __('Order has been marked as paid', 'fluent-cart') |
| 793 | 927 | ]); |
| 794 | 928 | } |
| @@ -814,11 +948,8 @@ | ||
| 814 | 948 | 'message' => __('Orders selection is required', 'fluent-cart') |
| 815 | 949 | ]); |
| 816 | 950 | } |
| 817 | 951 | |
| 818 | - $orders = Order::query()->whereIn('id', $orderIds)->get(); | |
| 819 | - | |
| 820 | - | |
| 821 | 952 | if ($action == 'delete_orders') { |
| 822 | 953 | |
| 823 | 954 | $isDeleted = OrderResource::bulkDeleteByOrderIds($orderIds); |
| 824 | 955 | |
| @@ -850,17 +981,16 @@ | ||
| 850 | 981 | // } |
| 851 | 982 | |
| 852 | 983 | |
| 853 | 984 | } |
| 854 | - if ($action == 'capture_payments') { | |
| 855 | - foreach ($orders as $order) { | |
| 856 | - $order->capturePayments(); | |
| 857 | - } | |
| 858 | 985 | |
| 859 | - return [ | |
| 860 | - 'message' => __('Selected payments has been successfully captured', 'fluent-cart') | |
| 861 | - ]; | |
| 862 | - } | |
| 986 | + // The capture_payments branch was removed: it called | |
| 987 | + // $order->capturePayments(), a method that has never existed anywhere | |
| 988 | + // in the codebase, so the action fataled on the first order (audit | |
| 989 | + // item #43). No UI sends it — the orders bulk bar submits | |
| 990 | + // delete_test_orders only. Bulk payment capture, if wanted, is a | |
| 991 | + // gateway feature to design (authorize/capture per gateway), not a | |
| 992 | + // branch to resurrect as-is. | |
| 863 | 993 | |
| 864 | 994 | return $this->sendError([ |
| 865 | 995 | 'message' => __('Selected action is invalid', 'fluent-cart') |
| 866 | 996 | ]); |
| @@ -961,15 +1091,54 @@ | ||
| 961 | 1091 | 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart') |
| 962 | 1092 | ]); |
| 963 | 1093 | } |
| 964 | 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 | + | |
| 965 | 1103 | $transaction->updateStatus($newStatus); |
| 966 | - $order->updatePaymentStatus($newStatus); | |
| 967 | 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 | + } | |
| 1112 | + | |
| 968 | 1113 | return [ |
| 969 | 1114 | 'transaction' => $transaction, |
| 970 | 1115 | 'message' => __('Payment status has been successfully updated', 'fluent-cart') |
| 971 | 1116 | ]; |
| 1117 | + } | |
| 1118 | + | |
| 1119 | + public function syncPendingTransaction(Request $request, $order, OrderTransaction $transaction) | |
| 1120 | + { | |
| 1121 | + $order = Order::query()->find($order); | |
| 1122 | + | |
| 1123 | + if (!$order || $transaction->order_id != $order->id) { | |
| 1124 | + return $this->sendError([ | |
| 1125 | + 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart') | |
| 1126 | + ]); | |
| 1127 | + } | |
| 1128 | + | |
| 1129 | + $result = $transaction->syncPendingTransaction(); | |
| 1130 | + | |
| 1131 | + if (is_wp_error($result)) { | |
| 1132 | + return $this->sendError([ | |
| 1133 | + 'message' => $result->get_error_message() | |
| 1134 | + ]); | |
| 1135 | + } | |
| 1136 | + | |
| 1137 | + return $this->sendSuccess([ | |
| 1138 | + 'message' => __('Transaction has been synced from the payment gateway successfully!', 'fluent-cart'), | |
| 1139 | + 'transaction' => $result | |
| 1140 | + ]); | |
| 972 | 1141 | } |
| 973 | 1142 | |
| 974 | 1143 | public function getStats($orderUuid): \WP_REST_Response |
| 975 | 1144 | { |