Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 183 additions & 2 deletions Model/Api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
}
Expand All @@ -819,6 +825,21 @@ public function returnOrder($creditmemo)
);
}

// 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)) {
$orderTax = (float) $order->getBaseTaxAmount();
$refundTotal = (float) $creditmemo->getBaseGrandTotal();
$isTaxOnlyRefund = $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(
'apiLoginID' => $this->getApiId(),
'apiKey' => $this->getApiKey(),
Expand Down Expand Up @@ -906,9 +927,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
Expand Down
78 changes: 74 additions & 4 deletions Test/Unit/Model/ApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading