From 127ab5dbff8ad69dc3803580ea6f879b98b33225 Mon Sep 17 00:00:00 2001 From: Brett Ammeson Date: Fri, 20 Mar 2026 10:35:25 -0400 Subject: [PATCH] DEV-7187: Validate exemption certificate covers destination state before applying Single-state exemption certificates were being applied to all orders regardless of shipping destination, because the cert ID was passed to TaxCloud's Lookup unconditionally. Now we call GetExemptCertificates to check the cert's exempt states against the destination state, with 1-hour caching. Fails closed (no exemption) if the SOAP call errors. Co-Authored-By: Claude Opus 4.6 --- Model/Api.php | 126 +++++++++++- Test/Unit/Model/ApiTest.php | 372 +++++++++++++++++++++++++++++++++++- 2 files changed, 495 insertions(+), 3 deletions(-) diff --git a/Model/Api.php b/Model/Api.php index e58d33f..c556a9d 100644 --- a/Model/Api.php +++ b/Model/Api.php @@ -283,6 +283,123 @@ private function isFallbackToMagentoEnabled() ); } + /** + * Check whether an exemption certificate covers the destination state. + * + * Calls GetExemptCertificates via SOAP, caches the result, and returns + * the certificate ID only when the destination state appears in the + * certificate's ExemptStates list. Returns null otherwise, so the + * lookup proceeds without an exemption. + * + * @param string $certificateID + * @param string $customerID + * @param string $destinationState Two-letter state abbreviation + * @return string|null The certificate ID if it covers the state, null otherwise + */ + private function getValidatedCertificateID($certificateID, $customerID, $destinationState) + { + if (empty($certificateID) || empty($customerID) || empty($destinationState)) { + return null; + } + + // Check cache first — keyed per certificate so it survives across quotes + $cacheKey = 'taxcloud_cert_states_' . $certificateID; + $cached = $this->cacheType->load($cacheKey); + if ($cached) { + $exemptStates = json_decode($cached, true); + if (is_array($exemptStates)) { + $match = in_array($destinationState, $exemptStates, true); + $this->tclogger->info( + 'Exemption cert ' . $certificateID . ' covers [' . implode(', ', $exemptStates) . ']' + . ' — destination ' . $destinationState . ($match ? ' MATCHES' : ' does NOT match') + ); + return $match ? $certificateID : null; + } + } + + // Fetch certificate details from TaxCloud + $client = $this->getClient(); + if (!$client) { + $this->tclogger->info('Cannot validate exemption cert: no SOAP client'); + return null; + } + + try { + $response = $client->GetExemptCertificates(array( + 'apiLoginID' => $this->getApiId(), + 'apiKey' => $this->getApiKey(), + 'customerID' => $customerID, + )); + } catch (Throwable $e) { + // Fail closed — don't apply an unverified exemption + $this->tclogger->info('GetExemptCertificates SOAP error: ' . $e->getMessage()); + return null; + } + + $exemptStates = $this->extractExemptStatesFromResponse($response, $certificateID); + + // Cache for 1 hour so we don't hammer the SOAP endpoint on every page load + $this->cacheType->save( + json_encode($exemptStates), + $cacheKey, + [], + 3600 + ); + + $match = in_array($destinationState, $exemptStates, true); + $this->tclogger->info( + 'Exemption cert ' . $certificateID . ' covers [' . implode(', ', $exemptStates) . ']' + . ' — destination ' . $destinationState . ($match ? ' MATCHES' : ' does NOT match') + ); + return $match ? $certificateID : null; + } + + /** + * Extract the list of exempt state abbreviations for a specific certificate + * from a GetExemptCertificates SOAP response. + * + * @param object $response Raw SOAP response + * @param string $certificateID + * @return string[] State abbreviations (e.g. ['NY', 'NJ']) + */ + private function extractExemptStatesFromResponse($response, $certificateID) + { + $result = $response->GetExemptCertificatesResult ?? null; + if (!$result || ($result->ResponseType ?? '') !== 'OK') { + $this->tclogger->info('GetExemptCertificates returned non-OK response'); + return []; + } + + $certs = $result->ExemptCertificates->ExemptionCertificate ?? []; + // SOAP may return a single object instead of an array when there is only one cert + if (!is_array($certs)) { + $certs = [$certs]; + } + + foreach ($certs as $cert) { + if (($cert->CertificateID ?? '') !== $certificateID) { + continue; + } + + $states = []; + $exemptStates = $cert->Detail->ExemptStates->ExemptState ?? []; + if (!is_array($exemptStates)) { + $exemptStates = [$exemptStates]; + } + foreach ($exemptStates as $es) { + // The SOAP response uses StateAbbr or StateAbbreviation + $abbr = $es->StateAbbr ?? $es->StateAbbreviation ?? null; + if ($abbr) { + $states[] = $abbr; + } + } + return $states; + } + + $this->tclogger->info('Certificate ' . $certificateID . ' not found in GetExemptCertificates response'); + return []; + } + /** * Set customer address data from quote address * @param \Magento\Customer\Api\Data\AddressInterface $customerAddress @@ -456,8 +573,13 @@ public function lookupTaxes($itemsByType, $shippingAssignment, $quote) $certificateID = null; if ($customer) { $certificate = $customer->getCustomAttribute('taxcloud_cert'); - if ($certificate) { - $certificateID = $certificate->getValue(); + if ($certificate && $certificate->getValue()) { + // Only apply the exemption when the cert actually covers the destination state + $certificateID = $this->getValidatedCertificateID( + $certificate->getValue(), + $customer->getId(), + $destination['State'] + ); } } diff --git a/Test/Unit/Model/ApiTest.php b/Test/Unit/Model/ApiTest.php index e3c3525..c6e3386 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', 'lookup', 'authorizedWithCapture', 'OrderDetails']) + ->addMethods(['Returned', 'lookup', 'authorizedWithCapture', 'OrderDetails', 'GetExemptCertificates']) ->getMock(); $this->mockDataObject = $this->getMockBuilder(DataObject::class) ->disableOriginalConstructor() @@ -836,4 +836,374 @@ public function testLookupTaxesCacheKeyUsesParamsAfterEvent() $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'); } + + // ─── Exemption Certificate State Filtering Tests ──────────────────── + + /** + * Build a mock GetExemptCertificates SOAP response. + * + * @param string $certID Certificate UUID + * @param string[] $stateAbbrs e.g. ['NY', 'NJ'] + * @return \stdClass + */ + private function buildGetExemptCertsResponse(string $certID, array $stateAbbrs): \stdClass + { + $exemptStates = []; + foreach ($stateAbbrs as $abbr) { + $es = new \stdClass(); + $es->StateAbbr = $abbr; + $es->StateAbbreviation = $abbr; + $es->ReasonForExemption = 'Resale'; + $es->IdentificationNumber = '12345'; + $exemptStates[] = $es; + } + + $detail = new \stdClass(); + $detail->ExemptStates = new \stdClass(); + $detail->ExemptStates->ExemptState = $exemptStates; + + $cert = new \stdClass(); + $cert->CertificateID = $certID; + $cert->Detail = $detail; + + $result = new \stdClass(); + $result->ResponseType = 'OK'; + $result->ExemptCertificates = new \stdClass(); + $result->ExemptCertificates->ExemptionCertificate = [$cert]; + + $response = new \stdClass(); + $response->GetExemptCertificatesResult = $result; + return $response; + } + + /** + * Common setup for the exemption-certificate lookup tests. + * + * Returns an array [$itemsByType, $shippingAssignment, $quote, &$lookupParams] + * so each test can call lookupTaxes() and inspect what was sent to the SOAP lookup. + * + * @param string $certID Certificate UUID on the customer (empty string = no cert) + * @param string $destinationState Two-letter state code for the shipping address + * @return array + */ + private function setUpLookupWithCert(string $certID, string $destinationState): array + { + $this->scopeConfig = $this->createMock(ScopeConfigInterface::class); + $this->cacheType = $this->createMock(CacheInterface::class); + $this->eventManager = $this->createMock(ManagerInterface::class); + $this->soapClientFactory = $this->createMock(ClientFactory::class); + $this->objectFactory = $this->createMock(DataObjectFactory::class); + $this->productFactory = $this->createMock(ProductFactory::class); + $this->regionFactory = $this->createMock(RegionFactory::class); + $this->logger = $this->createMock(Logger::class); + $this->serializer = $this->createMock(SerializerInterface::class); + $this->cartItemResponseHandler = $this->createMock(CartItemResponseHandler::class); + $this->productTicService = $this->createMock(ProductTicService::class); + $this->mockSoapClient = $this->getMockBuilder(\SoapClient::class) + ->disableOriginalConstructor() + ->addMethods(['Returned', 'lookup', 'authorizedWithCapture', 'OrderDetails', 'GetExemptCertificates']) + ->getMock(); + $this->mockDataObject = $this->getMockBuilder(DataObject::class) + ->disableOriginalConstructor() + ->addMethods(['setParams', 'getParams', 'setResult', 'getResult']) + ->getMock(); + + $this->api = new Api( + $this->scopeConfig, + $this->cacheType, + $this->eventManager, + $this->soapClientFactory, + $this->objectFactory, + $this->productFactory, + $this->regionFactory, + $this->logger, + $this->serializer, + $this->cartItemResponseHandler, + $this->productTicService + ); + $this->injectMockSoapClientIntoApi(); + + $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($destinationState); + $this->regionFactory->method('create')->willReturn($region); + + $certAttr = null; + if ($certID !== '') { + $certAttr = $this->createMock(\Magento\Framework\Api\AttributeValue::class); + $certAttr->method('getValue')->willReturn($certID); + } + + $customer = $this->createMock(\Magento\Customer\Api\Data\CustomerInterface::class); + $customer->method('getId')->willReturn(42); + $customer->method('getCustomAttribute') + ->willReturnCallback(function ($attr) use ($certAttr) { + return $attr === 'taxcloud_cert' ? $certAttr : null; + }); + + $quote = $this->createMock(\Magento\Quote\Model\Quote::class); + $quote->method('getCustomer')->willReturn($customer); + $quote->method('getId')->willReturn(999); + + $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(5.00); + + $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(5.00); + + $itemsByType = [ + Api::ITEM_TYPE_SHIPPING => [ + 'shipping' => [Api::KEY_ITEM => $shippingTaxDetailItem], + ], + ]; + + $this->productTicService->method('getShippingTic')->willReturn('11010'); + + // DataObject pass-through for event dispatch + $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); + + // Standard lookup SOAP response + $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; + } + ); + + return [$itemsByType, $shippingAssignment, $quote, &$lookupParams]; + } + + /** + * @dataProvider exemptCertSoapProvider + */ + public function testLookupTaxesExemptCertStateFilteringViaSoap( + string $description, + array $certExemptStates, + string $destinationState, + bool $expectCertSent + ) { + $certID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + [$itemsByType, $shippingAssignment, $quote, &$lookupParams] = + $this->setUpLookupWithCert($certID, $destinationState); + + $this->mockSoapClient->method('GetExemptCertificates') + ->willReturn($this->buildGetExemptCertsResponse($certID, $certExemptStates)); + + $this->cacheType->method('load')->willReturn(false); + + $this->api->lookupTaxes($itemsByType, $shippingAssignment, $quote); + + $this->assertNotNull($lookupParams, 'lookup should have been called'); + if ($expectCertSent) { + $this->assertSame($certID, $lookupParams['exemptCert']['CertificateID'], $description); + } else { + $this->assertNull($lookupParams['exemptCert']['CertificateID'], $description); + } + } + + public static function exemptCertSoapProvider(): array + { + return [ + 'cert covers destination state (exact match)' => [ + 'Cert covering GA should be sent when shipping to GA', + ['GA'], + 'GA', + true, + ], + 'cert covers destination among multiple states' => [ + 'Cert covering GA+NY should be sent when shipping to GA', + ['GA', 'NY'], + 'GA', + true, + ], + 'cert does not cover destination state' => [ + 'Cert covering only NY must not be sent when shipping to GA', + ['NY'], + 'GA', + false, + ], + 'cert covers different states, none match destination' => [ + 'Cert covering NY+NJ must not be sent when shipping to TX', + ['NY', 'NJ'], + 'TX', + false, + ], + 'cert has no exempt states' => [ + 'Cert with empty exempt states must not be sent', + [], + 'GA', + false, + ], + ]; + } + + /** + * @dataProvider exemptCertCacheProvider + */ + public function testLookupTaxesExemptCertStateFilteringViaCache( + string $description, + array $cachedStates, + string $destinationState, + bool $expectCertSent + ) { + $certID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + [$itemsByType, $shippingAssignment, $quote, &$lookupParams] = + $this->setUpLookupWithCert($certID, $destinationState); + + $certCacheKey = 'taxcloud_cert_states_' . $certID; + $this->cacheType->method('load')->willReturnCallback(function ($key) use ($certCacheKey, $cachedStates) { + if ($key === $certCacheKey) { + return json_encode($cachedStates); + } + return false; + }); + + // Cache hit means no SOAP call needed + $this->mockSoapClient->expects($this->never())->method('GetExemptCertificates'); + + $this->api->lookupTaxes($itemsByType, $shippingAssignment, $quote); + + $this->assertNotNull($lookupParams, 'lookup should have been called'); + if ($expectCertSent) { + $this->assertSame($certID, $lookupParams['exemptCert']['CertificateID'], $description); + } else { + $this->assertNull($lookupParams['exemptCert']['CertificateID'], $description); + } + } + + public static function exemptCertCacheProvider(): array + { + return [ + 'cached states include destination' => [ + 'Cached GA+NY should allow cert when shipping to GA', + ['GA', 'NY'], + 'GA', + true, + ], + 'cached states do not include destination' => [ + 'Cached NY must block cert when shipping to GA', + ['NY'], + 'GA', + false, + ], + ]; + } + + /** + * GetExemptCertificates SOAP call fails → fail closed, cert not applied. + */ + public function testLookupTaxesOmitsCertWhenGetExemptCertificatesFails() + { + $certID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + [$itemsByType, $shippingAssignment, $quote, &$lookupParams] = + $this->setUpLookupWithCert($certID, 'GA'); + + $this->mockSoapClient->method('GetExemptCertificates') + ->willThrowException(new \SoapFault('SOAP-ERROR', 'Service unavailable')); + + $this->cacheType->method('load')->willReturn(false); + + $this->api->lookupTaxes($itemsByType, $shippingAssignment, $quote); + + $this->assertNotNull($lookupParams, 'lookup should have been called'); + $this->assertNull( + $lookupParams['exemptCert']['CertificateID'], + 'CertificateID must be null when GetExemptCertificates SOAP call fails' + ); + } + + /** + * No cert on customer → CertificateID should be null (unchanged behavior). + */ + public function testLookupTaxesNoCertOnCustomerSendsNullCertificateID() + { + [$itemsByType, $shippingAssignment, $quote, &$lookupParams] = + $this->setUpLookupWithCert('', 'GA'); + + $this->cacheType->method('load')->willReturn(false); + $this->mockSoapClient->expects($this->never())->method('GetExemptCertificates'); + + $this->api->lookupTaxes($itemsByType, $shippingAssignment, $quote); + + $this->assertNotNull($lookupParams, 'lookup should have been called'); + $this->assertNull( + $lookupParams['exemptCert']['CertificateID'], + 'CertificateID should be null when customer has no cert' + ); + } + + /** + * Single-cert SOAP response (SOAP returns object instead of array for one cert). + */ + public function testLookupTaxesHandlesSingleCertSoapResponse() + { + $certID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + [$itemsByType, $shippingAssignment, $quote, &$lookupParams] = + $this->setUpLookupWithCert($certID, 'GA'); + + // Build response where ExemptionCertificate is a single object, not an array + $response = $this->buildGetExemptCertsResponse($certID, ['GA']); + $response->GetExemptCertificatesResult->ExemptCertificates->ExemptionCertificate = + $response->GetExemptCertificatesResult->ExemptCertificates->ExemptionCertificate[0]; + + $this->mockSoapClient->method('GetExemptCertificates')->willReturn($response); + $this->cacheType->method('load')->willReturn(false); + + $this->api->lookupTaxes($itemsByType, $shippingAssignment, $quote); + + $this->assertNotNull($lookupParams, 'lookup should have been called'); + $this->assertSame( + $certID, + $lookupParams['exemptCert']['CertificateID'], + 'Should handle single-cert SOAP response (object instead of array)' + ); + } } \ No newline at end of file