| @@ -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\Order\OrderBulkAction; | |
| 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; |
| @@ -41,9 +38,8 @@ | ||
| 41 | 38 | use FluentCart\App\Models\SubscriptionMeta; |
| 42 | 39 | use FluentCart\App\Services\Filter\OrderFilter; |
| 43 | 40 | use FluentCart\App\Services\Payments\PaymentHelper; |
| 44 | 41 | use FluentCart\App\Services\Reminders\ReminderService; |
| 45 | -use FluentCart\App\Services\DateTime\DateTime; | |
| 46 | 42 | use FluentCart\App\Services\Payments\Refund; |
| 47 | 43 | use FluentCart\App\Services\URL; |
| 48 | 44 | use FluentCart\Framework\Http\Request\Request; |
| 49 | 45 | use FluentCart\Framework\Support\Arr; |
| @@ -78,14 +74,27 @@ | ||
| 78 | 74 | public function store(OrderRequest $request) |
| 79 | 75 | { |
| 80 | 76 | $data = $request->getSafe($request->sanitize()); |
| 81 | 77 | $type = 'payment'; |
| 82 | - $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); | |
| 83 | 92 | if ($hasSubscription) { |
| 84 | 93 | $type = 'subscription'; |
| 85 | 94 | // right now we don't support subscription with manual order |
| 86 | - $isSubscriptionAllowedInManualOrder = apply_filters('fluent_cart/order/is_subscription_allowed_in_manual_order', false, [ | |
| 87 | - '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 | |
| 88 | 97 | ]); |
| 89 | 98 | |
| 90 | 99 | if (!$isSubscriptionAllowedInManualOrder) { |
| 91 | 100 | return $this->sendError([ |
| @@ -113,20 +122,86 @@ | ||
| 113 | 122 | 'uuid' => $order->uuid |
| 114 | 123 | ]); |
| 115 | 124 | } |
| 116 | 125 | |
| 117 | - | |
| 118 | 126 | public static function hasSubscription($orderItems): bool |
| 119 | 127 | { |
| 120 | - // check order items for subscription, payment_type == subscription | |
| 121 | 128 | foreach ($orderItems as $item) { |
| 122 | 129 | if (Arr::get($item, 'payment_type') == 'subscription' || Arr::get($item, 'other_info.payment_type') == 'subscription') { |
| 123 | 130 | return true; |
| 124 | 131 | } |
| 125 | 132 | } |
| 133 | + | |
| 126 | 134 | return false; |
| 127 | 135 | } |
| 128 | 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 | + | |
| 129 | 204 | public function updateOrder(OrderRequest $request, $order_id) |
| 130 | 205 | { |
| 131 | 206 | $order = Order::query()->find($order_id); |
| 132 | 207 | |
| @@ -203,13 +278,29 @@ | ||
| 203 | 278 | |
| 204 | 279 | if (is_wp_error($data)) { |
| 205 | 280 | return $this->sendError($data->get_error_message()); |
| 206 | 281 | } |
| 282 | + | |
| 283 | + // Changing the order address recalculates tax server-side | |
| 284 | + // (OrderResource::updateOrderAddressId → reapplyTaxAfterUpdate). Return the | |
| 285 | + // refreshed order with the tax appends so the admin UI can update the tax | |
| 286 | + // summary, totals and payment status without a full page reload. | |
| 287 | + $freshOrder = Order::query()->where('id', $order_id) | |
| 288 | + ->addAppends([ | |
| 289 | + 'business_info', | |
| 290 | + 'customer_tax_number', | |
| 291 | + 'is_b2b_order', | |
| 292 | + 'display_tax_lines', | |
| 293 | + 'display_shipping_tax_lines', | |
| 294 | + 'is_reverse_charge_tax_order', | |
| 295 | + 'tax_summary', | |
| 296 | + ]) | |
| 297 | + ->first(); | |
| 298 | + | |
| 207 | 299 | return $this->sendSuccess([ |
| 208 | - 'message' => 'Address updated successfully' | |
| 300 | + 'message' => __('Address updated successfully', 'fluent-cart'), | |
| 301 | + 'order' => $freshOrder, | |
| 209 | 302 | ]); |
| 210 | - | |
| 211 | - | |
| 212 | 303 | } |
| 213 | 304 | |
| 214 | 305 | public function generateMissingLicenses(Request $request, Order $order) |
| 215 | 306 | { |
| @@ -234,8 +325,14 @@ | ||
| 234 | 325 | |
| 235 | 326 | } |
| 236 | 327 | |
| 237 | 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 | + * | |
| 238 | 335 | * @throws ValidationException |
| 239 | 336 | */ |
| 240 | 337 | public function refundOrder(Request $request, $orderId) |
| 241 | 338 | { |
| @@ -246,11 +343,16 @@ | ||
| 246 | 343 | 'message' => __('Order can not be refunded.', 'fluent-cart') |
| 247 | 344 | ], 400); |
| 248 | 345 | } |
| 249 | 346 | |
| 250 | - $refundInfo = $request->get('refund_info', []); | |
| 347 | + $refundInfo = (array)$request->get('refund_info', []); | |
| 251 | 348 | |
| 252 | - $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, [ | |
| 253 | 355 | 'transaction_id' => 'required', |
| 254 | 356 | 'amount' => 'required', |
| 255 | 357 | ], [ |
| 256 | 358 | 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'), |
| @@ -256,11 +358,15 @@ | ||
| 256 | 358 | 'transaction_id.required' => __('Transaction ID is required', 'fluent-cart'), |
| 257 | 359 | 'amount.required' => __('Refund amount is required', 'fluent-cart'), |
| 258 | 360 | ]); |
| 259 | 361 | |
| 260 | - $transaction = OrderTransaction::query()->findOrFail($refundInfo['transaction_id']); | |
| 261 | - $refundAmount = Helper::toCent($refundInfo['amount']); | |
| 362 | + if ($validator->validate()->fails()) { | |
| 363 | + return $this->sendError($validator->errors(), 422); | |
| 364 | + } | |
| 262 | 365 | |
| 366 | + $transaction = OrderTransaction::query()->where('order_id', $orderId)->findOrFail($refundInfo['transaction_id']); | |
| 367 | + $refundAmount = Helper::roundCent($refundInfo['amount']); | |
| 368 | + | |
| 263 | 369 | // refund on our end |
| 264 | 370 | $result = (new Refund())->processRefund($transaction, $refundAmount, $refundInfo); |
| 265 | 371 | |
| 266 | 372 | if (is_wp_error($result)) { |
| @@ -313,9 +419,10 @@ | ||
| 313 | 419 | $cancelSubscription = Arr::get($refundInfo, 'cancelSubscription') == 'true'; |
| 314 | 420 | |
| 315 | 421 | if ($cancelSubscription && $transaction->subscription_id && $transaction->subscription) { |
| 316 | 422 | $vendorSubscriptionCancelled = $transaction->subscription->cancelRemoteSubscription([ |
| 317 | - 'reason' => 'refunded' | |
| 423 | + 'reason' => 'refunded', | |
| 424 | + 'effective_from' => 'immediately' | |
| 318 | 425 | ]); |
| 319 | 426 | if (is_wp_error($vendorSubscriptionCancelled)) { |
| 320 | 427 | $responseData['subscription_cancel']['status'] = 'failed'; |
| 321 | 428 | $responseData['subscription_cancel']['message'] = $vendorSubscriptionCancelled->get_error_message(); |
| @@ -517,9 +624,9 @@ | ||
| 517 | 624 | // Must run before deleteOrderRelatedData() which removes stock_movement meta and order items. |
| 518 | 625 | (new OrderDeleting($order, $connectedOrderIds, $isTestMode, $order->type))->dispatch(); |
| 519 | 626 | |
| 520 | 627 | // Pre-load relations before cleanup so the delete events have address data |
| 521 | - $order->load('customer', 'shipping_address', 'billing_address'); | |
| 628 | + $order->load(['customer', 'shipping_address', 'billing_address']); | |
| 522 | 629 | |
| 523 | 630 | $this->deleteOrderRelatedData($connectedOrderIds, $isTestMode); |
| 524 | 631 | $DB->commit(); |
| 525 | 632 | } catch (\Exception $e) { |
| @@ -613,18 +720,52 @@ | ||
| 613 | 720 | |
| 614 | 721 | if (empty($data['order']['receipt_url'])) { |
| 615 | 722 | $data['order']['receipt_url'] = $url; |
| 616 | 723 | } |
| 617 | - $meta = OrderMeta::query()->where('order_id', $orderId) | |
| 618 | - ->where('meta_key', 'vat_tax_id') | |
| 619 | - ->first(); | |
| 724 | + $taxNumber = Arr::get($data, 'order.customer_tax_number', ''); | |
| 725 | + if (!empty($taxNumber)) { | |
| 726 | + $data['tax_id'] = $taxNumber; | |
| 727 | + } | |
| 728 | + unset($data['order']['customer_tax_number']); | |
| 620 | 729 | |
| 621 | - if ($meta) { | |
| 622 | - $data['tax_id'] = $meta->meta_value; | |
| 730 | + $data['can_send_payment_reminder'] = (new ReminderService())->canSendPaymentReminder($data['order']); | |
| 731 | + | |
| 732 | + return $data; | |
| 733 | + } | |
| 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 | + ); | |
| 623 | 751 | } |
| 624 | 752 | |
| 625 | - $data['can_send_payment_reminder'] = (new ReminderService())->canSendPaymentReminder($data['order']); | |
| 753 | + $data = $this->getDetails($orderId); | |
| 626 | 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 | + | |
| 627 | 768 | return $data; |
| 628 | 769 | } |
| 629 | 770 | |
| 630 | 771 | public function createCustom(Request $request, OrderItemHelper $orderItemHelper, Order $order) |
| @@ -629,13 +770,18 @@ | ||
| 629 | 770 | |
| 630 | 771 | public function createCustom(Request $request, OrderItemHelper $orderItemHelper, Order $order) |
| 631 | 772 | { |
| 632 | 773 | try { |
| 633 | - return $orderItemHelper->processCustom( | |
| 774 | + $orderItem = $orderItemHelper->processCustom( | |
| 634 | 775 | $request->product, |
| 635 | 776 | $order->id |
| 636 | 777 | ); |
| 637 | 778 | |
| 779 | + return $this->sendSuccess([ | |
| 780 | + 'message' => __('Custom item has been added to the order!', 'fluent-cart'), | |
| 781 | + 'order_item' => $orderItem | |
| 782 | + ]); | |
| 783 | + | |
| 638 | 784 | } catch (\Exception $e) { |
| 639 | 785 | return $this->sendError([ |
| 640 | 786 | 'message' => $e->getMessage() |
| 641 | 787 | ], 423); |
| @@ -680,88 +826,103 @@ | ||
| 680 | 826 | |
| 681 | 827 | |
| 682 | 828 | public function markAsPaid(Request $request, Order $order) |
| 683 | 829 | { |
| 684 | - $dueAmount = intval($order->total_amount - $order->total_paid); | |
| 830 | + $db = Order::query()->getConnection(); | |
| 831 | + $db->beginTransaction(); | |
| 685 | 832 | |
| 686 | - if ($dueAmount <= 0) { | |
| 687 | - return $this->sendError([ | |
| 688 | - 'message' => __('Order has already been paid', 'fluent-cart') | |
| 689 | - ], 423); | |
| 690 | - } | |
| 833 | + try { | |
| 834 | + $locked = Order::query() | |
| 835 | + ->where('id', $order->id) | |
| 836 | + ->lockForUpdate() | |
| 837 | + ->first(); | |
| 691 | 838 | |
| 692 | - if (Arr::get($order, 'status') === 'canceled') { | |
| 693 | - return $this->sendError([ | |
| 694 | - 'message' => __('Unable to mark paid for canceled order', 'fluent-cart') | |
| 695 | - ], 423); | |
| 696 | - } | |
| 839 | + if (!$locked) { | |
| 840 | + $db->rollBack(); | |
| 841 | + return $this->sendError([ | |
| 842 | + 'message' => __('Order not found', 'fluent-cart') | |
| 843 | + ], 404); | |
| 844 | + } | |
| 697 | 845 | |
| 698 | - $transaction = $order->transactions->where('status', Status::TRANSACTION_PENDING) | |
| 699 | - ->where('payment_method', 'offline_payment') | |
| 700 | - ->first(); | |
| 846 | + $dueAmount = intval($locked->total_amount - $locked->total_paid); | |
| 701 | 847 | |
| 702 | - $newTransactionData = [ | |
| 703 | - 'total' => $dueAmount, | |
| 704 | - 'status' => Status::TRANSACTION_SUCCEEDED, | |
| 705 | - 'payment_method' => sanitize_text_field($request->payment_method), | |
| 706 | - 'vendor_charge_id' => sanitize_text_field($request->vendor_charge_id), | |
| 707 | - 'payment_mode' => sanitize_text_field($order->mode), | |
| 708 | - 'payment_method_type' => sanitize_text_field($request->payment_method), | |
| 709 | - 'order_type' => sanitize_text_field($order->type), | |
| 710 | - 'transaction_type' => sanitize_text_field($request->transaction_type), | |
| 711 | - 'currency' => sanitize_text_field($order->currency), | |
| 712 | - ]; | |
| 848 | + if ($dueAmount <= 0) { | |
| 849 | + $db->rollBack(); | |
| 850 | + return $this->sendError([ | |
| 851 | + 'message' => __('Order has already been paid', 'fluent-cart') | |
| 852 | + ], 423); | |
| 853 | + } | |
| 713 | 854 | |
| 714 | - if ($transaction) { | |
| 715 | - $transaction->update($newTransactionData); | |
| 716 | - } else { | |
| 717 | - $transaction = OrderTransaction::query()->create( | |
| 718 | - array_merge($newTransactionData, [ | |
| 719 | - 'order_id' => $order->id | |
| 720 | - ]) | |
| 721 | - ); | |
| 722 | - } | |
| 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 | + } | |
| 723 | 861 | |
| 724 | - $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(); | |
| 725 | 875 | |
| 726 | - $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 | + ]; | |
| 727 | 886 | |
| 728 | - if ($order->payment_status !== 'partially_refunded') { | |
| 729 | - $order->payment_status = Status::PAYMENT_PAID; | |
| 730 | - } | |
| 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 | + } | |
| 731 | 899 | |
| 732 | - $order->status = Status::ORDER_PROCESSING; | |
| 733 | - $order->total_paid = $order->total_amount; | |
| 734 | - $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'); | |
| 735 | 910 | |
| 736 | - $actionActivity = [ | |
| 737 | - 'title' => __('Order status updated', 'fluent-cart'), | |
| 738 | - 'content' => sprintf( | |
| 739 | - /* translators: 1: old status, 2: new status */ | |
| 740 | - __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), $oldStatus, $order->status) | |
| 741 | - ]; | |
| 911 | + $note = sanitize_text_field($request->get('mark_paid_note', '')); | |
| 912 | + if ($note) { | |
| 913 | + $locked->note = $note; | |
| 914 | + } | |
| 915 | + $locked->save(); | |
| 742 | 916 | |
| 743 | - // dispatching events related to order status update and payment paid | |
| 744 | - (new OrderPaid($order, $order->customer, $transaction))->dispatch(); | |
| 917 | + $db->commit(); | |
| 918 | + } catch (\Throwable $e) { | |
| 919 | + $db->rollBack(); | |
| 920 | + throw $e; | |
| 921 | + } | |
| 745 | 922 | |
| 746 | - (new OrderStatusUpdated($order, $oldStatus, $order->status, true, $actionActivity, 'order_status'))->dispatch(); | |
| 923 | + (new StatusHelper($locked))->syncOrderStatuses($transaction); | |
| 747 | 924 | |
| 748 | - // if digital | |
| 749 | - if ($order->fulfillment_type == 'digital' && $order->status === Status::ORDER_PROCESSING) { | |
| 750 | - $order->status = Status::ORDER_COMPLETED; | |
| 751 | - $order->completed_at = DateTime::gmtNow(); | |
| 752 | - $order->save(); | |
| 753 | - | |
| 754 | - $actionActivity = [ | |
| 755 | - 'title' => __('Order status updated', 'fluent-cart'), | |
| 756 | - 'content' => sprintf( | |
| 757 | - /* translators: 1: old status, 2: new status */ | |
| 758 | - __('Order status has been updated from %1$s to %2$s', 'fluent-cart'), Status::ORDER_PROCESSING, $order->status) | |
| 759 | - ]; | |
| 760 | - | |
| 761 | - (new OrderStatusUpdated($order, Status::ORDER_PROCESSING, $order->status, true, $actionActivity, 'order_status'))->dispatch(); | |
| 762 | - } | |
| 763 | - | |
| 764 | 925 | return $this->response->sendSuccess([ |
| 765 | 926 | 'message' => __('Order has been marked as paid', 'fluent-cart') |
| 766 | 927 | ]); |
| 767 | 928 | } |
| @@ -787,11 +948,8 @@ | ||
| 787 | 948 | 'message' => __('Orders selection is required', 'fluent-cart') |
| 788 | 949 | ]); |
| 789 | 950 | } |
| 790 | 951 | |
| 791 | - $orders = Order::query()->whereIn('id', $orderIds)->get(); | |
| 792 | - | |
| 793 | - | |
| 794 | 952 | if ($action == 'delete_orders') { |
| 795 | 953 | |
| 796 | 954 | $isDeleted = OrderResource::bulkDeleteByOrderIds($orderIds); |
| 797 | 955 | |
| @@ -823,168 +981,17 @@ | ||
| 823 | 981 | // } |
| 824 | 982 | |
| 825 | 983 | |
| 826 | 984 | } |
| 827 | - if ($action == 'change_shipping_status') { | |
| 828 | - $newStatus = sanitize_text_field($request->get('new_status', '')); | |
| 829 | - if (!$newStatus) { | |
| 830 | - return $this->sendError([ | |
| 831 | - 'message' => __('Please select status', 'fluent-cart') | |
| 832 | - ]); | |
| 833 | - } | |
| 834 | 985 | |
| 835 | - $validStatuses = Helper::getShippingStatuses(); | |
| 836 | - if (!isset($validStatuses[$newStatus])) { | |
| 837 | - return $this->sendError([ | |
| 838 | - 'message' => __('Provided shipping status is not valid', 'fluent-cart') | |
| 839 | - ]); | |
| 840 | - } | |
| 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. | |
| 841 | 993 | |
| 842 | - // foreach ($orders as $order) { | |
| 843 | - // $order->updateShippingStatus($newStatus); | |
| 844 | - // } | |
| 845 | - | |
| 846 | - return [ | |
| 847 | - 'message' => __('Shipping Status has been changed for the selected orders', 'fluent-cart') | |
| 848 | - ]; | |
| 849 | - | |
| 850 | - } | |
| 851 | - if ($action == 'change_order_status') { | |
| 852 | - | |
| 853 | - $newStatus = sanitize_text_field($request->get('new_status', '')); | |
| 854 | - if (!$newStatus) { | |
| 855 | - return $this->sendError([ | |
| 856 | - 'message' => __('Please select status', 'fluent-cart') | |
| 857 | - ]); | |
| 858 | - } | |
| 859 | - | |
| 860 | - $validStatuses = Status::getEditableOrderStatuses(); | |
| 861 | - if (!isset($validStatuses[$newStatus])) { | |
| 862 | - return $this->sendError([ | |
| 863 | - 'message' => __('Provided order status is not valid', 'fluent-cart') | |
| 864 | - ]); | |
| 865 | - } | |
| 866 | - | |
| 867 | - $failedOrderIds = []; | |
| 868 | - $updatedOrderIds = []; | |
| 869 | - | |
| 870 | - foreach ($orders as $order) { | |
| 871 | - // $order->updateStatus('status', $newStatus); | |
| 872 | - $isUpdated = OrderResource::updateStatuses([ | |
| 873 | - 'order' => $order, | |
| 874 | - 'action' => 'change_order_status', | |
| 875 | - 'statuses.order_status' => $newStatus, | |
| 876 | - 'manage_stock' => sanitize_text_field($request->get('manage_stock')), | |
| 877 | - ]); | |
| 878 | - | |
| 879 | - if (is_wp_error($isUpdated)) { | |
| 880 | - $failedOrderIds[] = $order->id; | |
| 881 | - } else { | |
| 882 | - $updatedOrderIds[] = $order->id; | |
| 883 | - } | |
| 884 | - } | |
| 885 | - | |
| 886 | - if (count($failedOrderIds) > 0) { | |
| 887 | - $failedOrderIds = implode(' , ', $failedOrderIds); | |
| 888 | - return count($updatedOrderIds) > 0 | |
| 889 | - ? $this->sendSuccess([ | |
| 890 | - 'message' => sprintf( | |
| 891 | - /* translators: %s is the order ids */ | |
| 892 | - __("The order ID - %s cannot be updated because they are either already cancelled or have the same status. And remaining order status has been successfully changed", 'fluent-cart'), $failedOrderIds) | |
| 893 | - ]) | |
| 894 | - : | |
| 895 | - $this->sendError([ | |
| 896 | - 'message' => sprintf( | |
| 897 | - /* translators: %s is the order ids */ | |
| 898 | - __("The order ID - %s cannot be updated because they are either already cancelled or have the same status.", 'fluent-cart'), $failedOrderIds) | |
| 899 | - ], 423); | |
| 900 | - } | |
| 901 | - | |
| 902 | - if (count($updatedOrderIds) > 0 && count($failedOrderIds) < 1) { | |
| 903 | - return $this->sendSuccess([ | |
| 904 | - 'message' => __('Order Status has been changed for the selected orders', 'fluent-cart') | |
| 905 | - ]); | |
| 906 | - } | |
| 907 | - } | |
| 908 | - | |
| 909 | - if ($action == 'capture_payments') { | |
| 910 | - foreach ($orders as $order) { | |
| 911 | - $order->capturePayments(); | |
| 912 | - } | |
| 913 | - | |
| 914 | - return [ | |
| 915 | - 'message' => __('Selected payments has been successfully captured', 'fluent-cart') | |
| 916 | - ]; | |
| 917 | - } | |
| 918 | - | |
| 919 | - if ($action == 'change_payment_status') { | |
| 920 | - $newStatus = sanitize_text_field($request->get('new_status', '')); | |
| 921 | - if (!$newStatus) { | |
| 922 | - return $this->sendError([ | |
| 923 | - 'message' => __('Please select status', 'fluent-cart') | |
| 924 | - ]); | |
| 925 | - } | |
| 926 | - | |
| 927 | - $validStatuses = Status::getEditableTransactionStatuses(); | |
| 928 | - if (!isset($validStatuses[$newStatus])) { | |
| 929 | - return $this->sendError([ | |
| 930 | - 'message' => __('Provided payment status is not valid', 'fluent-cart') | |
| 931 | - ]); | |
| 932 | - } | |
| 933 | - | |
| 934 | - $failedOrderIds = []; | |
| 935 | - $updatedOrderIds = []; | |
| 936 | - $count = 0; | |
| 937 | - $customerIds = []; | |
| 938 | - | |
| 939 | - foreach ($orders as $order) { | |
| 940 | - $transaction = $order->latest_transaction; | |
| 941 | - $isUpdated = OrderResource::updatePaymentStatus([ | |
| 942 | - 'order' => $order, | |
| 943 | - 'status' => $newStatus, | |
| 944 | - 'transaction' => $transaction, | |
| 945 | - ]); | |
| 946 | - | |
| 947 | - if (is_wp_error($isUpdated)) { | |
| 948 | - $failedOrderIds[] = $order->id; | |
| 949 | - } else { | |
| 950 | - $updatedOrderIds[] = $order->id; | |
| 951 | - $count++; | |
| 952 | - $customerIds[] = $order->customer_id; | |
| 953 | - } | |
| 954 | - } | |
| 955 | - | |
| 956 | - if ($count > 0 && count($customerIds) > 0) { | |
| 957 | - (new OrderBulkAction($customerIds))->dispatch(); | |
| 958 | - } | |
| 959 | - | |
| 960 | - if (count($failedOrderIds) > 0) { | |
| 961 | - $failedOrderIds = implode(' , ', $failedOrderIds); | |
| 962 | - return count($updatedOrderIds) > 0 | |
| 963 | - ? $this->sendSuccess([ | |
| 964 | - 'message' => sprintf( | |
| 965 | - /* translators: %s is the order ids */ | |
| 966 | - __("The order ID - %s cannot be updated at the moment because the transaction either already has the same status or does not match the provided order. The remaining order statuses have been updated successfully.", 'fluent-cart'), $failedOrderIds) | |
| 967 | - ]) | |
| 968 | - : | |
| 969 | - $this->sendError([ | |
| 970 | - 'message' => sprintf( | |
| 971 | - /* translators: %s is the order ids */ | |
| 972 | - __("The order ID - %s cannot be updated at the moment because its payment status is either the same as before or has already been refunded.", 'fluent-cart'), $failedOrderIds) | |
| 973 | - ], 423); | |
| 974 | - } | |
| 975 | - | |
| 976 | - if (count($updatedOrderIds) > 0 && count($failedOrderIds) < 1) { | |
| 977 | - return $this->sendSuccess([ | |
| 978 | - 'message' => sprintf( | |
| 979 | - /* translators: %s is the payment status */ | |
| 980 | - __("Selected orders payment status has been marked as %s", 'fluent-cart'), | |
| 981 | - $newStatus | |
| 982 | - ) | |
| 983 | - ]); | |
| 984 | - } | |
| 985 | - } | |
| 986 | - | |
| 987 | 994 | return $this->sendError([ |
| 988 | 995 | 'message' => __('Selected action is invalid', 'fluent-cart') |
| 989 | 996 | ]); |
| 990 | 997 | |
| @@ -1062,9 +1069,17 @@ | ||
| 1062 | 1069 | public function updateTransactionStatus(Request $request, $order, OrderTransaction $transaction) |
| 1063 | 1070 | { |
| 1064 | 1071 | |
| 1065 | 1072 | $order = Order::query()->find($order); |
| 1066 | - $newStatus = $request->get('status'); | |
| 1073 | + $newStatus = sanitize_text_field($request->get('status', '')); | |
| 1074 | + | |
| 1075 | + $validStatuses = Status::getEditableTransactionStatuses(); | |
| 1076 | + if (!isset($validStatuses[$newStatus])) { | |
| 1077 | + return $this->sendError([ | |
| 1078 | + 'message' => __('Provided transaction status is not valid', 'fluent-cart') | |
| 1079 | + ]); | |
| 1080 | + } | |
| 1081 | + | |
| 1067 | 1082 | if ($transaction->status == $newStatus) { |
| 1068 | 1083 | return $this->sendError([ |
| 1069 | 1084 | 'reload' => true, |
| 1070 | 1085 | 'message' => __('Transaction already has the same status', 'fluent-cart') |
| @@ -1076,11 +1091,26 @@ | ||
| 1076 | 1091 | 'message' => __('The selected transaction does not match with the provided order', 'fluent-cart') |
| 1077 | 1092 | ]); |
| 1078 | 1093 | } |
| 1079 | 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 | + | |
| 1080 | 1103 | $transaction->updateStatus($newStatus); |
| 1081 | - $order->updatePaymentStatus($newStatus); | |
| 1082 | 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 | + | |
| 1083 | 1113 | return [ |
| 1084 | 1114 | 'transaction' => $transaction, |
| 1085 | 1115 | 'message' => __('Payment status has been successfully updated', 'fluent-cart') |
| 1086 | 1116 | ]; |
| @@ -1085,8 +1115,32 @@ | ||
| 1085 | 1115 | 'message' => __('Payment status has been successfully updated', 'fluent-cart') |
| 1086 | 1116 | ]; |
| 1087 | 1117 | } |
| 1088 | 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 | + ]); | |
| 1141 | + } | |
| 1142 | + | |
| 1089 | 1143 | public function getStats($orderUuid): \WP_REST_Response |
| 1090 | 1144 | { |
| 1091 | 1145 | $order = OrderResource::find($orderUuid); |
| 1092 | 1146 | return $this->sendSuccess([ |
| @@ -1146,8 +1200,74 @@ | ||
| 1146 | 1200 | 'shipping_charge' => $totalShippingCharge, |
| 1147 | 1201 | 'order_items' => $orderItems |
| 1148 | 1202 | ]); |
| 1149 | 1203 | |
| 1204 | + } | |
| 1205 | + | |
| 1206 | + /** | |
| 1207 | + * Calculate tax for an admin order given an address and item list. | |
| 1208 | + * No DB writes — pure calculation helper. | |
| 1209 | + * | |
| 1210 | + * Accepts: { country, state, city, postcode, items: [{post_id, object_id, subtotal, discount_total}] } | |
| 1211 | + * Returns: { tax_total, shipping_tax, tax_lines, tax_behavior, tax_country } | |
| 1212 | + */ | |
| 1213 | + public function calculateTax(Request $request) | |
| 1214 | + { | |
| 1215 | + $address = [ | |
| 1216 | + 'country' => sanitize_text_field($request->get('country', '')), | |
| 1217 | + 'state' => sanitize_text_field($request->get('state', '')), | |
| 1218 | + 'city' => sanitize_text_field($request->get('city', '')), | |
| 1219 | + 'postcode' => sanitize_text_field($request->get('postcode', '')), | |
| 1220 | + ]; | |
| 1221 | + | |
| 1222 | + $rawItems = $request->get('items', []); | |
| 1223 | + if (!is_array($rawItems)) { | |
| 1224 | + $rawItems = []; | |
| 1225 | + } elseif (count($rawItems) > 100) { | |
| 1226 | + $rawItems = array_slice($rawItems, 0, 100); | |
| 1227 | + } | |
| 1228 | + | |
| 1229 | + $items = []; | |
| 1230 | + foreach ($rawItems as $item) { | |
| 1231 | + $items[] = [ | |
| 1232 | + 'post_id' => (int) Arr::get($item, 'post_id', 0), | |
| 1233 | + 'object_id' => (int) Arr::get($item, 'object_id', 0), | |
| 1234 | + 'subtotal' => (int) Arr::get($item, 'subtotal', 0), | |
| 1235 | + 'discount_total' => (int) Arr::get($item, 'discount_total', 0), | |
| 1236 | + 'shipping_charge' => (int) Arr::get($item, 'shipping_charge', 0), | |
| 1237 | + 'quantity' => max(1, (int) Arr::get($item, 'quantity', 1)), | |
| 1238 | + ]; | |
| 1239 | + } | |
| 1240 | + | |
| 1241 | + $result = \FluentCart\App\Services\Tax\AdminOrderTaxService::calculate($items, $address); | |
| 1242 | + | |
| 1243 | + if ($result === null) { | |
| 1244 | + return $this->sendSuccess([ | |
| 1245 | + 'tax_total' => 0, | |
| 1246 | + 'shipping_tax' => 0, | |
| 1247 | + 'tax_lines' => [], | |
| 1248 | + 'tax_behavior' => 0, | |
| 1249 | + 'tax_country' => '', | |
| 1250 | + ]); | |
| 1251 | + } | |
| 1252 | + | |
| 1253 | + $taxLines = Arr::get($result, 'tax_lines', []); | |
| 1254 | + $strippedTaxLines = array_values(array_map(function ($line) { | |
| 1255 | + return [ | |
| 1256 | + 'label' => Arr::get($line, 'label', ''), | |
| 1257 | + 'rate_percent' => Arr::get($line, 'rate_percent', 0), | |
| 1258 | + 'tax_amount' => (int) Arr::get($line, 'tax_amount', 0), | |
| 1259 | + 'inclusive' => (bool) Arr::get($line, 'inclusive', false), | |
| 1260 | + ]; | |
| 1261 | + }, $taxLines)); | |
| 1262 | + | |
| 1263 | + return $this->sendSuccess([ | |
| 1264 | + 'tax_total' => (int) Arr::get($result, 'tax_total', 0), | |
| 1265 | + 'shipping_tax' => (int) Arr::get($result, 'shipping_tax', 0), | |
| 1266 | + 'tax_behavior' => (int) Arr::get($result, 'tax_behavior', 0), | |
| 1267 | + 'tax_country' => Arr::get($result, 'tax_country', ''), | |
| 1268 | + 'tax_lines' => $strippedTaxLines, | |
| 1269 | + ]); | |
| 1150 | 1270 | } |
| 1151 | 1271 | |
| 1152 | 1272 | protected function prepareOrderItemsWithVariations($orderItems) |
| 1153 | 1273 | { |