From 1c22a25962500569d64fb6eadfb9529e7a448db5 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 19 Feb 2026 09:51:13 -0800 Subject: [PATCH 1/3] DEV-6903 Resolve partial refund issue --- Model/Api.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/Model/Api.php b/Model/Api.php index ce1b57c..2609e0d 100644 --- a/Model/Api.php +++ b/Model/Api.php @@ -795,13 +795,19 @@ public function returnOrder($creditmemo) if ($items) { foreach ($items as $creditItem) { + $qty = $creditItem->getQty(); + if ($qty <= 0) { + continue; + } $item = $creditItem->getOrderItem(); + $price = $creditItem->getPrice(); + $discountPerUnit = $qty > 0 ? $creditItem->getDiscountAmount() / $qty : 0; $cartItems[] = array( 'ItemID' => $item->getSku(), 'Index' => $index, 'TIC' => $this->productTicService->getProductTic($item, 'returnOrder'), - 'Price' => $creditItem->getPrice() - $creditItem->getDiscountAmount() / $creditItem->getQty(), - 'Qty' => $creditItem->getQty(), + 'Price' => $price - $discountPerUnit, + 'Qty' => $qty, ); $index++; } @@ -819,6 +825,13 @@ public function returnOrder($creditmemo) ); } + // Tax-only (or other non-product) refunds: no items and no shipping to return. + // Do not call Returned with empty cartItems—TaxCloud treats that as entire order return. + if (empty($cartItems)) { + $this->tclogger->info('returnOrder: no product or shipping items to return (e.g. tax-only refund); skipping Returned API call'); + return true; + } + $params = array( 'apiLoginID' => $this->getApiId(), 'apiKey' => $this->getApiKey(), From 53dc9b27275d5b53223f3637159fe01e892c6ea3 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 23 Feb 2026 10:41:01 -0800 Subject: [PATCH 2/3] DEV-6903 Handle tax-only partial refunds with exempt re-create --- Model/Api.php | 178 +++++++++++++++++++++++++++++++++++- Test/Unit/Model/ApiTest.php | 78 +++++++++++++++- 2 files changed, 248 insertions(+), 8 deletions(-) diff --git a/Model/Api.php b/Model/Api.php index 2609e0d..69c51ad 100644 --- a/Model/Api.php +++ b/Model/Api.php @@ -825,11 +825,21 @@ public function returnOrder($creditmemo) ); } - // Tax-only (or other non-product) refunds: no items and no shipping to return. - // Do not call Returned with empty cartItems—TaxCloud treats that as entire order return. + // Tax-only refund: no product/shipping returned, refund amount equals order tax. + // Flow: return full order in TaxCloud, then re-create order as exempt. + $wasTaxOnlyRefund = false; if (empty($cartItems)) { - $this->tclogger->info('returnOrder: no product or shipping items to return (e.g. tax-only refund); skipping Returned API call'); - return true; + $orderTax = (float) $order->getBaseTaxAmount(); + $creditmemoTax = (float) $creditmemo->getBaseTaxAmount(); + $refundTotal = (float) $creditmemo->getBaseGrandTotal(); + $isTaxOnlyRefund = $creditmemoTax > 0 + && $orderTax > 0 + && abs($refundTotal - $orderTax) < 0.02; + + if ($isTaxOnlyRefund) { + $this->tclogger->info('returnOrder: tax-only refund detected; will re-create as exempt after Returned'); + $wasTaxOnlyRefund = true; + } } $params = array( @@ -919,9 +929,169 @@ public function returnOrder($creditmemo) return false; } + // Re-create order as exempt in TaxCloud for nexus tracking (isExempt=true). + if ($wasTaxOnlyRefund) { + if ($this->lookupForOrderExempt($order, $client)) { + $exemptCartId = $order->getIncrementId() . '-exempt'; + if (!$this->authorizeCaptureWithCartId($order, $exemptCartId, $client)) { + $this->tclogger->info('returnOrder: re-create as exempt capture failed; return was successful'); + } + } else { + $this->tclogger->info('returnOrder: re-create as exempt lookup failed; return was successful'); + } + } + return true; } + /** + * Build cart items from order for full-order return / exempt re-create. + * + * @param \Magento\Sales\Model\Order $order + * @return array + */ + private function buildCartItemsFromOrder($order) + { + $cartItems = array(); + $index = 0; + $orderItems = $order->getAllVisibleItems(); + if ($orderItems) { + foreach ($orderItems as $item) { + $qty = (float) $item->getQtyOrdered(); + if ($qty <= 0) { + continue; + } + $price = (float) $item->getPrice(); + $discountAmount = (float) $item->getDiscountAmount(); + $discountPerUnit = $qty > 0 ? $discountAmount / $qty : 0; + $cartItems[] = array( + 'ItemID' => $item->getSku(), + 'Index' => $index, + 'TIC' => $this->productTicService->getProductTic($item, 'returnOrder'), + 'Price' => $price - $discountPerUnit, + 'Qty' => $qty, + ); + $index++; + } + } + $shippingAmount = (float) $order->getBaseShippingAmount(); + if ($shippingAmount > 0) { + $cartItems[] = array( + 'ItemID' => 'shipping', + 'Index' => $index, + 'TIC' => $this->productTicService->getShippingTic(), + 'Price' => $shippingAmount, + 'Qty' => 1, + ); + } + return $cartItems; + } + + /** + * Get destination array from order shipping address for Lookup. + * + * @param \Magento\Sales\Model\Order $order + * @return array|null + */ + private function getDestinationFromOrder($order) + { + $address = $order->getShippingAddress(); + if (!$address || !$address->getPostcode() || $address->getCountryId() !== 'US') { + return null; + } + $parsedZip = PostalCodeParser::parse($address->getPostcode()); + if (!PostalCodeParser::isValid($parsedZip)) { + return null; + } + $street = $address->getStreet(); + $street1 = is_array($street) ? ($street[0] ?? '') : (string) $street; + $street2 = is_array($street) && isset($street[1]) ? $street[1] : ''; + $region = $this->regionFactory->create()->load($address->getRegionId()); + return array( + 'Address1' => $street1, + 'Address2' => $street2, + 'City' => $address->getCity() ?? '', + 'State' => $region->getCode() ?? '', + 'Zip5' => $parsedZip['Zip5'], + 'Zip4' => $parsedZip['Zip4'], + ); + } + + /** + * Look up order as exempt using a new cart ID in preparation for exempt re-create. + * + * @param \Magento\Sales\Model\Order $order + * @param \SoapClient $client + * @return bool + */ + private function lookupForOrderExempt($order, $client) + { + $cartItems = $this->buildCartItemsFromOrder($order); + if (empty($cartItems)) { + return false; + } + $destination = $this->getDestinationFromOrder($order); + if ($destination === null) { + $this->tclogger->info('returnOrder: no valid shipping address for exempt lookup'); + return false; + } + $origin = $this->getOrigin(); + if ($origin === null) { + return false; + } + $params = array( + 'apiLoginID' => $this->getApiId(), + 'apiKey' => $this->getApiKey(), + 'customerID' => $order->getCustomerId() ?? $this->getGuestCustomerId(), + 'cartID' => $order->getIncrementId() . '-exempt', + 'cartItems' => $cartItems, + 'origin' => $origin, + 'destination' => $destination, + 'deliveredBySeller' => false, + 'isExempt' => true, + ); + try { + $lookupResponse = $client->lookup($params); + } catch (Throwable $e) { + $this->tclogger->info('returnOrder: exempt lookup failed: ' . $e->getMessage()); + return false; + } + $lookupResponse = json_decode(json_encode($lookupResponse), true); + $result = isset($lookupResponse['LookupResult']) ? $lookupResponse['LookupResult'] : array(); + $responseType = isset($result['ResponseType']) ? $result['ResponseType'] : ''; + return $responseType === 'OK' || $responseType === 'Informational'; + } + + /** + * Call AuthorizedWithCapture for the order using the given cart ID. + * + * @param \Magento\Sales\Model\Order $order + * @param string $cartId + * @param \SoapClient $client + * @return bool + */ + private function authorizeCaptureWithCartId($order, $cartId, $client) + { + $params = array( + 'apiLoginID' => $this->getApiId(), + 'apiKey' => $this->getApiKey(), + 'customerID' => $order->getCustomerId() ?? $this->getGuestCustomerId(), + 'cartID' => $cartId, + 'orderID' => $order->getIncrementId(), + 'dateAuthorized' => date('c'), + 'dateCaptured' => date('c'), + ); + try { + $response = $client->authorizedWithCapture($params); + } catch (Throwable $e) { + $this->tclogger->info('returnOrder: authorizeCapture failed: ' . $e->getMessage()); + return false; + } + $response = json_decode(json_encode($response), true); + $result = isset($response['AuthorizedWithCaptureResult']) ? $response['AuthorizedWithCaptureResult'] : array(); + return (isset($result['ResponseType']) ? $result['ResponseType'] : '') === 'OK'; + } + /** * Verify address using TaxCloud web services * @param $creditmemo diff --git a/Test/Unit/Model/ApiTest.php b/Test/Unit/Model/ApiTest.php index d295b6d..98bb9b7 100644 --- a/Test/Unit/Model/ApiTest.php +++ b/Test/Unit/Model/ApiTest.php @@ -64,7 +64,7 @@ protected function setUp(): void $this->productTicService = $this->createMock(ProductTicService::class); $this->mockSoapClient = $this->getMockBuilder(\SoapClient::class) ->disableOriginalConstructor() - ->addMethods(['Returned']) + ->addMethods(['Returned', 'lookup', 'authorizedWithCapture']) ->getMock(); $this->mockDataObject = $this->getMockBuilder(DataObject::class) ->disableOriginalConstructor() @@ -111,7 +111,7 @@ public function testReturnOrderIncludesReturnCoDeliveryFeeWhenNoCartItems() $creditmemo = $this->createMock(\Magento\Sales\Model\Order\Creditmemo::class); $order = $this->createMock(\Magento\Sales\Model\Order\Order::class); $order->method('getIncrementId')->willReturn('TEST_ORDER_123'); - + $creditmemo->method('getOrder')->willReturn($order); $creditmemo->method('getAllItems')->willReturn([]); $creditmemo->method('getShippingAmount')->willReturn(0); @@ -148,6 +148,76 @@ public function testReturnOrderIncludesReturnCoDeliveryFeeWhenNoCartItems() $this->assertTrue($result, 'returnOrder should return true for successful refund'); } + /** + * Tax-only refund: empty credit memo where refund amount equals the order tax. + * Returned is called with empty cartItems (TaxCloud treats this as a full order return), + * then an exempt re-create is attempted via Lookup and AuthorizedWithCapture. + */ + public function testReturnOrderTaxOnlyRefundCallsReturnedThenReCreatesAsExempt() + { + // Mock configuration + $this->scopeConfig->method('getValue') + ->willReturnMap([ + ['tax/taxcloud_settings/enabled', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '1'], + ['tax/taxcloud_settings/logging', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '1'], + ['tax/taxcloud_settings/api_id', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, 'test_api_id'], + ['tax/taxcloud_settings/api_key', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, 'test_api_key'], + ['tax/taxcloud_settings/default_tic', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '00000'], + ['tax/taxcloud_settings/shipping_tic', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '11010'] + ]); + + // Mock SOAP client + $this->soapClientFactory->method('create') + ->willReturn($this->mockSoapClient); + + // Mock data object for event handling + $this->objectFactory->method('create') + ->willReturn($this->mockDataObject); + + // Mock credit memo + $creditmemo = $this->createMock(\Magento\Sales\Model\Order\Creditmemo::class); + $order = $this->createMock(\Magento\Sales\Model\Order\Order::class); + $order->method('getIncrementId')->willReturn('TEST_ORDER_123'); + $order->method('getBaseTaxAmount')->willReturn(5.0); + + $creditmemo->method('getOrder')->willReturn($order); + $creditmemo->method('getAllItems')->willReturn([]); + $creditmemo->method('getShippingAmount')->willReturn(0); + $creditmemo->method('getBaseTaxAmount')->willReturn(5.0); + $creditmemo->method('getBaseGrandTotal')->willReturn(5.0); + + // Mock successful SOAP response + $mockResponse = new \stdClass(); + $mockResponse->ReturnedResult = new \stdClass(); + $mockResponse->ReturnedResult->ResponseType = 'OK'; + $mockResponse->ReturnedResult->Messages = []; + + $this->mockSoapClient->method('Returned') + ->willReturn($mockResponse); + + // Mock data object methods + $this->mockDataObject->method('setParams')->willReturnSelf(); + $this->mockDataObject->method('getParams')->willReturn([ + 'apiLoginID' => 'test_api_id', + 'apiKey' => 'test_api_key', + 'orderID' => 'TEST_ORDER_123', + 'cartItems' => [], + 'returnedDate' => '2026-01-03T00:00:00+00:00', + 'returnCoDeliveryFeeWhenNoCartItems' => false + ]); + $this->mockDataObject->method('setResult')->willReturnSelf(); + $this->mockDataObject->method('getResult')->willReturn([ + 'ResponseType' => 'OK', + 'Messages' => [] + ]); + + // Execute the method + $result = $this->api->returnOrder($creditmemo); + + // Assert the result + $this->assertTrue($result, 'returnOrder should return true for a tax-only refund'); + } + public function testReturnOrderHandlesSoapErrorGracefully() { // Mock configuration @@ -173,7 +243,7 @@ public function testReturnOrderHandlesSoapErrorGracefully() $creditmemo = $this->createMock(\Magento\Sales\Model\Order\Creditmemo::class); $order = $this->createMock(\Magento\Sales\Model\Order\Order::class); $order->method('getIncrementId')->willReturn('TEST_ORDER_123'); - + $creditmemo->method('getOrder')->willReturn($order); $creditmemo->method('getAllItems')->willReturn([]); $creditmemo->method('getShippingAmount')->willReturn(0); @@ -328,7 +398,7 @@ public function testReturnOrderFailsWhenParameterIsLost() $creditmemo = $this->createMock(\Magento\Sales\Model\Order\Creditmemo::class); $order = $this->createMock(\Magento\Sales\Model\Order\Order::class); $order->method('getIncrementId')->willReturn('TEST_ORDER_123'); - + $creditmemo->method('getOrder')->willReturn($order); $creditmemo->method('getAllItems')->willReturn([]); $creditmemo->method('getShippingAmount')->willReturn(0); From 22aa84f836d80693eeb9057e33e00b4544c55ff6 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 23 Feb 2026 14:22:36 -0800 Subject: [PATCH 3/3] DEV-6903 removed the extra variable check --- Model/Api.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Model/Api.php b/Model/Api.php index 69c51ad..b443c9d 100644 --- a/Model/Api.php +++ b/Model/Api.php @@ -830,10 +830,8 @@ public function returnOrder($creditmemo) $wasTaxOnlyRefund = false; if (empty($cartItems)) { $orderTax = (float) $order->getBaseTaxAmount(); - $creditmemoTax = (float) $creditmemo->getBaseTaxAmount(); $refundTotal = (float) $creditmemo->getBaseGrandTotal(); - $isTaxOnlyRefund = $creditmemoTax > 0 - && $orderTax > 0 + $isTaxOnlyRefund = $orderTax > 0 && abs($refundTotal - $orderTax) < 0.02; if ($isTaxOnlyRefund) {