diff --git a/Model/Api.php b/Model/Api.php index bc42d1b..a5ee765 100644 --- a/Model/Api.php +++ b/Model/Api.php @@ -384,13 +384,15 @@ public function lookupTaxes($itemsByType, $shippingAssignment, $quote) } if (isset($itemsByType[self::ITEM_TYPE_SHIPPING])) { + $addressShippingAmount = (float) $address->getShippingAmount(); foreach ($itemsByType[self::ITEM_TYPE_SHIPPING] as $code => $itemTaxDetail) { // Shipping as a cart item - shipping needs to be taxed + $shippingRowTotal = $itemTaxDetail[self::KEY_ITEM]->getRowTotal(); $cartItems[] = array( 'ItemID' => 'shipping', 'Index' => $index++, 'TIC' => $this->productTicService->getShippingTic(), - 'Price' => $itemTaxDetail[self::KEY_ITEM]->getRowTotal(), + 'Price' => ($shippingRowTotal ?: $addressShippingAmount), 'Qty' => 1, ); } @@ -429,7 +431,22 @@ public function lookupTaxes($itemsByType, $shippingAssignment, $quote) ), ); - // hash, check cache + // Call before event (observers may modify $params, e.g. address verification) + $lookupParamsHolder = $this->objectFactory->create(); + $lookupParamsHolder->setParams($params); + + $this->eventManager->dispatch('taxcloud_lookup_before', array( + 'obj' => $lookupParamsHolder, + 'customer' => $customer, + 'address' => $address, + 'quote' => $quote, + 'itemsByType' => $itemsByType, + 'shippingAssignment' => $shippingAssignment, + )); + + $params = $lookupParamsHolder->getParams(); + + // hash, check cache (use post-observer params so cache key matches what we send to TaxCloud) $cacheKeyApi = 'taxcloud_rates_' . hash('sha256', json_encode($params)); $cacheResult = null; if ($this->cacheType->load($cacheKeyApi)) { @@ -448,21 +465,6 @@ public function lookupTaxes($itemsByType, $shippingAssignment, $quote) return $result; } - // Call before event - $obj = $this->objectFactory->create(); - $obj->setParams($params); - - $this->eventManager->dispatch('taxcloud_lookup_before', array( - 'obj' => $obj, - 'customer' => $customer, - 'address' => $address, - 'quote' => $quote, - 'itemsByType' => $itemsByType, - 'shippingAssignment' => $shippingAssignment, - )); - - $params = $obj->getParams(); - // Call the TaxCloud web service $this->tclogger->info('Calling lookupTaxes LIVE API'); diff --git a/Observer/Sales/Address.php b/Observer/Sales/Address.php index ec18bb2..5b36c1e 100644 --- a/Observer/Sales/Address.php +++ b/Observer/Sales/Address.php @@ -96,6 +96,8 @@ public function execute( $result = $this->tcapi->verifyAddress($params['destination']); if ($result) { + $result['Address1'] = $result['Address1'] ?: ($params['destination']['Address1'] ?? ''); + $result['Address2'] = $result['Address2'] ?: ($params['destination']['Address2'] ?? ''); $params['destination'] = $result; $obj->setParams($params); } diff --git a/Test/Unit/Mocks/MagentoMocks.php b/Test/Unit/Mocks/MagentoMocks.php index 9ec7806..672381a 100644 --- a/Test/Unit/Mocks/MagentoMocks.php +++ b/Test/Unit/Mocks/MagentoMocks.php @@ -202,6 +202,7 @@ class Event { public function getName() { return ''; } public function getOrder() { return null; } + public function getObj() { return null; } } } @@ -335,6 +336,8 @@ public function debug($message) { /* do nothing */ } namespace Magento\Quote\Model { class Quote { + public function getId() { return null; } + public function getCustomer() { return null; } public function getCustomerTaxClassId() { return null; } public function getStoreId() { return 1; } } @@ -446,11 +449,16 @@ public function create(); } namespace Magento\Customer\Api\Data { + interface CustomerInterface + { + public function getId(); + } + interface AddressInterfaceFactory { public function create(); } - + interface RegionInterfaceFactory { public function create(); diff --git a/Test/Unit/Model/ApiTest.php b/Test/Unit/Model/ApiTest.php index 85ae9c6..e3c3525 100644 --- a/Test/Unit/Model/ApiTest.php +++ b/Test/Unit/Model/ApiTest.php @@ -628,4 +628,212 @@ public function testGetOrderDetailsReturnsNullWhenNotOkOrError() $this->assertNull($result, 'getOrderDetails should return null when ResponseType is not OK'); } + + /** + * lookupTaxes: when shipping row total is 0, uses address getShippingAmount() for shipping price sent to TaxCloud. + */ + public function testLookupTaxesUsesAddressShippingAmountWhenShippingRowTotalIsZero() + { + $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, '0'], + ['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/cache_lifetime', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '0'], + ['tax/taxcloud_settings/guest_customer_id', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '-1'], + ['shipping/origin/postcode', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '60005'], + ['shipping/origin/street_line1', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '71 W Seegers Rd'], + ['shipping/origin/street_line2', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, ''], + ['shipping/origin/city', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, 'Arlington Heights'], + ['shipping/origin/region_id', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '1'], + ]); + + $region = $this->createMock(\Magento\Directory\Model\Region::class); + $region->method('load')->willReturnSelf(); + $region->method('getCode')->willReturn('GA'); + $this->regionFactory->method('create')->willReturn($region); + + $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + $customer->method('getId')->willReturn(1); + + $quote = $this->createMock(\Magento\Quote\Model\Quote::class); + $quote->method('getCustomer')->willReturn($customer); + + $address = $this->createMock(\Magento\Quote\Model\Quote\Address::class); + $address->method('getPostcode')->willReturn('30097'); + $address->method('getStreet')->willReturn(['405 Victorian Ln']); + $address->method('getCity')->willReturn('Duluth'); + $address->method('getRegionId')->willReturn(1); + $address->method('getCountryId')->willReturn('US'); + $address->method('getShippingAmount')->willReturn(13.85); + + $shipping = $this->createMock(\Magento\Quote\Model\Quote\Address::class); + $shipping->method('getAddress')->willReturn($address); + + $shippingAssignment = $this->createMock(\Magento\Quote\Api\Data\ShippingAssignmentInterface::class); + $shippingAssignment->method('getShipping')->willReturn($shipping); + $shippingAssignment->method('getItems')->willReturn([]); + + $shippingTaxDetailItem = $this->createMock(\Magento\Tax\Api\Data\QuoteDetailsItemInterface::class); + $shippingTaxDetailItem->method('getRowTotal')->willReturn(0); + + $itemsByType = [ + Api::ITEM_TYPE_SHIPPING => [ + 'shipping' => [Api::KEY_ITEM => $shippingTaxDetailItem], + ], + ]; + + $this->productTicService->method('getShippingTic')->willReturn('11010'); + + $this->cacheType->method('load')->willReturn(false); + + $capturedParams = null; + $this->mockDataObject->method('setParams')->willReturnCallback(function ($p) use (&$capturedParams) { + $capturedParams = $p; + return $this->mockDataObject; + }); + $this->mockDataObject->method('getParams')->willReturnCallback(function () use (&$capturedParams) { + return $capturedParams; + }); + $this->mockDataObject->method('setResult')->willReturnSelf(); + $this->mockDataObject->method('getResult')->willReturn([ + 'ResponseType' => 'OK', + 'CartItemsResponse' => ['CartItemResponse' => [['CartItemIndex' => 0, 'TaxAmount' => 0]]], + ]); + $this->objectFactory->method('create')->willReturn($this->mockDataObject); + + $lookupParams = null; + $mockLookupResponse = new \stdClass(); + $mockLookupResponse->LookupResult = new \stdClass(); + $mockLookupResponse->LookupResult->ResponseType = 'OK'; + $mockLookupResponse->LookupResult->CartItemsResponse = new \stdClass(); + $mockLookupResponse->LookupResult->CartItemsResponse->CartItemResponse = [ + (object)['CartItemIndex' => 0, 'TaxAmount' => 0], + ]; + $this->mockSoapClient->method('lookup')->willReturnCallback(function ($params) use (&$lookupParams, $mockLookupResponse) { + $lookupParams = $params; + return $mockLookupResponse; + }); + + $this->api->lookupTaxes($itemsByType, $shippingAssignment, $quote); + + $this->assertNotNull($lookupParams, 'lookup should have been called'); + $cartItems = $lookupParams['cartItems'] ?? []; + $shippingItem = null; + foreach ($cartItems as $item) { + if (isset($item['ItemID']) && $item['ItemID'] === 'shipping') { + $shippingItem = $item; + break; + } + } + $this->assertNotNull($shippingItem, 'cartItems should contain shipping'); + $this->assertSame(13.85, (float) $shippingItem['Price'], 'lookupTaxes should send address getShippingAmount() when shipping row total is 0'); + } + + /** + * lookupTaxes: cache key is computed from params after taxcloud_lookup_before event. + */ + public function testLookupTaxesCacheKeyUsesParamsAfterEvent() + { + $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, '0'], + ['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/cache_lifetime', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '3600'], + ['tax/taxcloud_settings/guest_customer_id', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '-1'], + ['shipping/origin/postcode', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '60005'], + ['shipping/origin/street_line1', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '71 W Seegers Rd'], + ['shipping/origin/street_line2', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, ''], + ['shipping/origin/city', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, 'Arlington Heights'], + ['shipping/origin/region_id', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '1'], + ]); + + $region = $this->createMock(\Magento\Directory\Model\Region::class); + $region->method('load')->willReturnSelf(); + $region->method('getCode')->willReturn('GA'); + $this->regionFactory->method('create')->willReturn($region); + + $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + $customer->method('getId')->willReturn(1); + $quote = $this->createMock(\Magento\Quote\Model\Quote::class); + $quote->method('getCustomer')->willReturn($customer); + + $address = $this->createMock(\Magento\Quote\Model\Quote\Address::class); + $address->method('getPostcode')->willReturn('30097'); + $address->method('getStreet')->willReturn(['405 Victorian Ln']); + $address->method('getCity')->willReturn('Duluth'); + $address->method('getRegionId')->willReturn(1); + $address->method('getCountryId')->willReturn('US'); + $address->method('getShippingAmount')->willReturn(0); + + $shipping = $this->createMock(\Magento\Quote\Model\Quote\Address::class); + $shipping->method('getAddress')->willReturn($address); + $shippingAssignment = $this->createMock(\Magento\Quote\Api\Data\ShippingAssignmentInterface::class); + $shippingAssignment->method('getShipping')->willReturn($shipping); + $shippingAssignment->method('getItems')->willReturn([]); + + $shippingTaxDetailItem = $this->createMock(\Magento\Tax\Api\Data\QuoteDetailsItemInterface::class); + $shippingTaxDetailItem->method('getRowTotal')->willReturn(0); + $itemsByType = [ + Api::ITEM_TYPE_SHIPPING => [ + 'shipping' => [Api::KEY_ITEM => $shippingTaxDetailItem], + ], + ]; + + $this->productTicService->method('getShippingTic')->willReturn('11010'); + $this->cacheType->method('load')->willReturn(false); + + $modifiedDestination = [ + 'Address1' => 'Modified Street By Observer', + 'Address2' => '', + 'City' => 'Duluth', + 'State' => 'GA', + 'Zip5' => '30097', + 'Zip4' => '', + ]; + $this->mockDataObject->method('setParams')->willReturnSelf(); + $this->mockDataObject->method('getParams')->willReturnCallback(function () use ($modifiedDestination) { + $base = [ + 'apiLoginID' => 'test_api_id', + 'apiKey' => 'test_api_key', + 'customerID' => 1, + 'cartID' => null, + 'cartItems' => [['ItemID' => 'shipping', 'Index' => 0, 'TIC' => '11010', 'Price' => 0, 'Qty' => 1]], + 'origin' => ['Address1' => '71 W Seegers Rd', 'City' => 'Arlington Heights', 'State' => 'GA', 'Zip5' => '60005', 'Zip4' => null], + 'destination' => $modifiedDestination, + 'deliveredBySeller' => false, + 'exemptCert' => ['CertificateID' => null], + ]; + return $base; + }); + $this->mockDataObject->method('setResult')->willReturnSelf(); + $this->mockDataObject->method('getResult')->willReturn([ + 'ResponseType' => 'OK', + 'CartItemsResponse' => ['CartItemResponse' => [['CartItemIndex' => 0, 'TaxAmount' => 0]]], + ]); + $this->objectFactory->method('create')->willReturn($this->mockDataObject); + + $cacheKeyUsed = null; + $this->cacheType->method('load')->willReturnCallback(function ($key) use (&$cacheKeyUsed) { + $cacheKeyUsed = $key; + return false; + }); + + $mockLookupResponse = new \stdClass(); + $mockLookupResponse->LookupResult = new \stdClass(); + $mockLookupResponse->LookupResult->ResponseType = 'OK'; + $mockLookupResponse->LookupResult->CartItemsResponse = new \stdClass(); + $mockLookupResponse->LookupResult->CartItemsResponse->CartItemResponse = [ + (object)['CartItemIndex' => 0, 'TaxAmount' => 0], + ]; + $this->mockSoapClient->method('lookup')->willReturn($mockLookupResponse); + + $this->api->lookupTaxes($itemsByType, $shippingAssignment, $quote); + + $expectedKey = 'taxcloud_rates_' . hash('sha256', json_encode($this->mockDataObject->getParams())); + $this->assertSame($expectedKey, $cacheKeyUsed, 'lookupTaxes cache key should be computed from params after taxcloud_lookup_before event'); + } } \ No newline at end of file diff --git a/Test/Unit/Observer/Sales/AddressTest.php b/Test/Unit/Observer/Sales/AddressTest.php new file mode 100644 index 0000000..531ea51 --- /dev/null +++ b/Test/Unit/Observer/Sales/AddressTest.php @@ -0,0 +1,160 @@ +createMock(\Magento\Framework\App\Config\ScopeConfigInterface::class); + $scopeConfig->method('getValue') + ->willReturnMap([ + ['tax/taxcloud_settings/enabled', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '1'], + ['tax/taxcloud_settings/verify_address', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '0'] + ]); + + $tcapi = $this->createMock(\Taxcloud\Magento2\Model\Api::class); + $tcapi->expects($this->never())->method('verifyAddress'); + + $logger = $this->createMock(\Taxcloud\Magento2\Logger\Logger::class); + + $observer = new Address($scopeConfig, $tcapi, $logger); + + $event = $this->createMock(\Magento\Framework\Event::class); + $observerObj = $this->createMock(\Magento\Framework\Event\Observer::class); + $observerObj->method('getEvent')->willReturn($event); + + $observer->execute($observerObj); + } + + /** + * When verifyAddress returns a result with empty Address1, observer preserves original Address1/Address2. + */ + public function testExecutePreservesStreetWhenVerifiedResultHasEmptyAddress1() + { + $scopeConfig = $this->createMock(\Magento\Framework\App\Config\ScopeConfigInterface::class); + $scopeConfig->method('getValue') + ->willReturnMap([ + ['tax/taxcloud_settings/enabled', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '1'], + ['tax/taxcloud_settings/verify_address', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '1'] + ]); + + $originalDestination = [ + 'Address1' => '405 Victorian Ln', + 'Address2' => 'Apt 2', + 'City' => 'Duluth', + 'State' => 'GA', + 'Zip5' => '30097', + 'Zip4' => '', + ]; + + $verifiedResult = [ + 'Address1' => '', + 'Address2' => '', + 'City' => 'Duluth', + 'State' => 'GA', + 'Zip5' => '30097', + 'Zip4' => '', + ]; + + $tcapi = $this->createMock(\Taxcloud\Magento2\Model\Api::class); + $tcapi->method('verifyAddress')->with($originalDestination)->willReturn($verifiedResult); + + $logger = $this->createMock(\Taxcloud\Magento2\Logger\Logger::class); + + $params = [ + 'destination' => $originalDestination, + 'origin' => [], + ]; + + $obj = $this->getMockBuilder(\Magento\Framework\DataObject::class) + ->disableOriginalConstructor() + ->addMethods(['setParams', 'getParams']) + ->getMock(); + $obj->method('getParams')->willReturn($params); + $obj->expects($this->once())->method('setParams')->with($this->callback(function ($updated) use ($originalDestination) { + return isset($updated['destination']['Address1']) + && $updated['destination']['Address1'] === $originalDestination['Address1'] + && isset($updated['destination']['Address2']) + && $updated['destination']['Address2'] === $originalDestination['Address2']; + }))->willReturnSelf(); + + $event = $this->createMock(\Magento\Framework\Event::class); + $event->method('getObj')->willReturn($obj); + + $observerObj = $this->createMock(\Magento\Framework\Event\Observer::class); + $observerObj->method('getEvent')->willReturn($event); + + $observer = new Address($scopeConfig, $tcapi, $logger); + $observer->execute($observerObj); + } + + /** + * When verifyAddress returns a result with non-empty Address1, observer uses verified result as-is. + */ + public function testExecuteUsesVerifiedDestinationWhenAddress1IsPresent() + { + $scopeConfig = $this->createMock(\Magento\Framework\App\Config\ScopeConfigInterface::class); + $scopeConfig->method('getValue') + ->willReturnMap([ + ['tax/taxcloud_settings/enabled', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '1'], + ['tax/taxcloud_settings/verify_address', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '1'] + ]); + + $originalDestination = [ + 'Address1' => '405 Victorian Ln', + 'Address2' => '', + 'City' => 'Duluth', + 'State' => 'GA', + 'Zip5' => '30097', + 'Zip4' => '', + ]; + + $verifiedResult = [ + 'Address1' => '405 Victorian Ln', + 'Address2' => 'Unit B', + 'City' => 'Duluth', + 'State' => 'GA', + 'Zip5' => '30097', + 'Zip4' => '1234', + ]; + + $tcapi = $this->createMock(\Taxcloud\Magento2\Model\Api::class); + $tcapi->method('verifyAddress')->with($originalDestination)->willReturn($verifiedResult); + + $logger = $this->createMock(\Taxcloud\Magento2\Logger\Logger::class); + + $params = ['destination' => $originalDestination]; + + $obj = $this->getMockBuilder(\Magento\Framework\DataObject::class) + ->disableOriginalConstructor() + ->addMethods(['setParams', 'getParams']) + ->getMock(); + $obj->method('getParams')->willReturn($params); + $obj->expects($this->once())->method('setParams')->with($this->callback(function ($updated) use ($verifiedResult) { + return $updated['destination'] === $verifiedResult; + }))->willReturnSelf(); + + $event = $this->createMock(\Magento\Framework\Event::class); + $event->method('getObj')->willReturn($obj); + + $observerObj = $this->createMock(\Magento\Framework\Event\Observer::class); + $observerObj->method('getEvent')->willReturn($event); + + $observer = new Address($scopeConfig, $tcapi, $logger); + $observer->execute($observerObj); + } +}