diff --git a/Model/Api.php b/Model/Api.php index b443c9d..bc42d1b 100644 --- a/Model/Api.php +++ b/Model/Api.php @@ -942,6 +942,163 @@ public function returnOrder($creditmemo) return true; } + /** + * Get order details from TaxCloud (OrderDetails API). + * Returns OrderDetailsResult with LookupDate, AuthorizedDate, CapturedDate, ReturnedDate, etc. + * + * @param \Magento\Sales\Model\Order $order + * @return array|null OrderDetailsResult as array, or null on failure / order not found + */ + public function getOrderDetails($order) + { + $this->tclogger->info('Calling getOrderDetails for order ' . $order->getIncrementId()); + + $client = $this->getClient(); + if (!$client) { + $this->tclogger->info('Error in getOrderDetails: Cannot get SoapClient'); + return null; + } + + $params = array( + 'apiLoginID' => $this->getApiId(), + 'apiKey' => $this->getApiKey(), + 'orderID' => $order->getIncrementId(), + ); + + try { + $response = $client->OrderDetails($params); + } catch (Throwable $e) { + $this->tclogger->info('getOrderDetails failed: ' . $e->getMessage()); + return null; + } + + $response = json_decode(json_encode($response), true); + if (empty($response['OrderDetailsResult'])) { + return null; + } + + $result = $response['OrderDetailsResult']; + if (isset($result['ResponseType']) && $result['ResponseType'] !== 'OK') { + $this->tclogger->info('getOrderDetails returned non-OK: ' . ($result['ResponseType'] ?? 'unknown')); + return null; + } + + return $result; + } + + /** + * Return canceled order using TaxCloud web services (no invoice; reverses capture) + * @param $order + * @return bool + */ + public function returnOrderCancellation($order) + { + $this->tclogger->info('Calling returnOrderCancellation'); + + $client = $this->getClient(); + + if (!$client) { + $this->tclogger->info('Error encountered during returnOrderCancellation: Cannot get SoapClient'); + return false; + } + + $cartItems = $this->buildCartItemsFromOrder($order); + + if (empty($cartItems)) { + $this->tclogger->info('returnOrderCancellation: no cart items for order ' . $order->getIncrementId()); + return false; + } + + $params = array( + 'apiLoginID' => $this->getApiId(), + 'apiKey' => $this->getApiKey(), + 'orderID' => $order->getIncrementId(), + 'cartItems' => $cartItems, + 'returnedDate' => date('c'), // date('Y-m-d') . 'T00:00:00' + 'returnCoDeliveryFeeWhenNoCartItems' => false + ); + + // Call before event + $obj = $this->objectFactory->create(); + $obj->setParams($params); + + $this->eventManager->dispatch('taxcloud_returned_before', array( + 'obj' => $obj, + 'order' => $order, + 'items' => $order->getAllVisibleItems(), + 'creditmemo' => null, + )); + + $params = $obj->getParams(); + + // Ensure returnCoDeliveryFeeWhenNoCartItems is always present + if (!isset($params['returnCoDeliveryFeeWhenNoCartItems'])) { + $params['returnCoDeliveryFeeWhenNoCartItems'] = false; + } + + $this->tclogger->info('returnOrderCancellation PARAMS:'); + $this->tclogger->info(print_r($params, true)); + + // Ensure all required parameters are properly set for SOAP call + $soapParams = array( + 'apiLoginID' => $params['apiLoginID'], + 'apiKey' => $params['apiKey'], + 'orderID' => $params['orderID'], + 'cartItems' => $params['cartItems'], + 'returnedDate' => $params['returnedDate'], + 'returnCoDeliveryFeeWhenNoCartItems' => $params['returnCoDeliveryFeeWhenNoCartItems'] + ); + + $this->tclogger->info('returnOrderCancellation SOAP PARAMS:'); + $this->tclogger->info(print_r($soapParams, true)); + + try { + $returnResponse = $client->Returned($soapParams); + } catch (Throwable $e) { + $this->tclogger->info('First attempt failed: ' . $e->getMessage()); + // Retry + try { + $returnResponse = $client->Returned($soapParams); + } catch (Throwable $e) { + $this->tclogger->info('Error encountered during returnOrderCancellation: ' . $e->getMessage()); + $this->tclogger->info('SOAP parameters that failed: ' . print_r($soapParams, true)); + return false; + } + } + + // Force into array + $returnResponse = json_decode(json_encode($returnResponse), true); + + $this->tclogger->info('returnOrderCancellation RESPONSE:'); + $this->tclogger->info(print_r($returnResponse, true)); + + $returnResult = $returnResponse['ReturnedResult']; + + // Call after event + $obj = $this->objectFactory->create(); + $obj->setResult($returnResult); + + $this->eventManager->dispatch('taxcloud_returned_after', array( + 'obj' => $obj, + 'order' => $order, + 'items' => $order->getAllVisibleItems(), + 'creditmemo' => null, + )); + + $returnResult = $obj->getResult(); + + if (!$returnResult || $returnResult['ResponseType'] != 'OK') { + $errorMessage = 'Unknown error'; + if ($returnResult && isset($returnResult['Messages']['ResponseMessage']['Message'])) { + $errorMessage = $returnResult['Messages']['ResponseMessage']['Message']; + } + $this->tclogger->info('Error encountered during returnOrderCancellation: ' . $errorMessage); + return false; + } + + return true; + } + /** * Build cart items from order for full-order return / exempt re-create. * diff --git a/Observer/Sales/Cancel.php b/Observer/Sales/Cancel.php new file mode 100644 index 0000000..6ca8f9b --- /dev/null +++ b/Observer/Sales/Cancel.php @@ -0,0 +1,154 @@ + + * @copyright 2021 The Federal Tax Authority, LLC d/b/a TaxCloud + * @license http://opensource.org/licenses/osl-3.0.php Open Software License (OSL 3.0) + */ + +namespace Taxcloud\Magento2\Observer\Sales; + +use \Magento\Framework\Event\ObserverInterface; +use \Magento\Framework\Event\Observer; +use \Magento\Sales\Model\Order; + +class Cancel implements ObserverInterface +{ + + /** + * Core store config + * + * @var \Magento\Framework\App\Config\ScopeConfigInterface + */ + protected $scopeConfig = null; + + /** + * TaxCloud Api Object + * + * @var \Taxcloud\Magento2\Model\Api + */ + protected $tcapi; + + /** + * TaxCloud Logger + * + * @var \Taxcloud\Magento2\Logger\Logger + */ + protected $tclogger; + + /** + * @param \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig + * @param \Taxcloud\Magento2\Model\Api $tcapi + * @param \Taxcloud\Magento2\Logger\Logger $tclogger + */ + public function __construct( + \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig, + \Taxcloud\Magento2\Model\Api $tcapi, + \Taxcloud\Magento2\Logger\Logger $tclogger + ) { + $this->scopeConfig = $scopeConfig; + $this->tcapi = $tcapi; + + if ($scopeConfig->getValue( + 'tax/taxcloud_settings/logging', + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + )) { + $this->tclogger = $tclogger; + } else { + $this->tclogger = new class { + public function info() + { + } + }; + } + } + + /** + * @param Observer $observer + */ + public function execute( + Observer $observer + ) { + if (!$this->scopeConfig->getValue( + 'tax/taxcloud_settings/enabled', + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + )) { + return; + } + + $eventName = $observer->getEvent()->getName(); + + if ($eventName === 'order_cancel_after') { + $order = $observer->getEvent()->getOrder(); + if ($order && $order->getId()) { + $this->processOrderCancel($order); + } + return; + } + + if ($eventName === 'sales_order_save_after') { + $order = $observer->getEvent()->getOrder(); + if (!$order || !$order->getId()) { + return; + } + $origState = $order->getOrigData('state'); + if ($origState !== Order::STATE_CANCELED && $order->getState() === Order::STATE_CANCELED) { + $this->tclogger->info( + 'TaxCloud Cancel: detected state transition to canceled via sales_order_save_after, order ' + . $order->getIncrementId() + ); + $this->processOrderCancel($order); + } + } + } + + /** + * If the order was captured in TaxCloud and has no invoices, call Returned to reverse the sale. + * + * @param Order $order + */ + protected function processOrderCancel(Order $order) + { + if ($order->getState() !== Order::STATE_CANCELED) { + $this->tclogger->info( + 'TaxCloud Cancel: skipping order ' . $order->getIncrementId() . ' (state is not canceled)' + ); + return; + } + + if ($order->getInvoiceCollection()->getSize() > 0) { + $this->tclogger->info( + 'TaxCloud Cancel: skipping order ' . $order->getIncrementId() . ' (order has invoices, use refund flow)' + ); + return; + } + + $details = $this->tcapi->getOrderDetails($order); + if (!$details || empty($details['CapturedDate'])) { + $this->tclogger->info( + 'TaxCloud Cancel: skipping order ' . $order->getIncrementId() + . ' (order was not captured in TaxCloud or OrderDetails unavailable)' + ); + return; + } + + $this->tclogger->info( + 'TaxCloud Cancel: calling Returned for canceled unpaid order ' . $order->getIncrementId() + ); + + if ($this->tcapi->returnOrderCancellation($order)) { + $this->tclogger->info( + 'TaxCloud Cancel: Returned completed for order ' . $order->getIncrementId() + ); + } + } +} diff --git a/README.md b/README.md index 71a6195..bdb398e 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,25 @@ When a credit memo is refunded in Magento, the extension automatically processes - Provides event hooks for extension customization - Ensures all required parameters are present for API calls +#### Order Cancellation (Unpaid Orders) + +When an order is canceled before any invoice is created, the extension automatically sends TaxCloud's `Returned` API so the sale is not reported. This ensures you do not remit tax on orders that were never paid (e.g. Check/Money Order, Bank Transfer, COD, or any order canceled with no invoice). + +##### How Canceled Unpaid Orders Are Handled + +1. **Cancellation detected**: The extension listens to `order_cancel_after` and, as a fallback, to `sales_order_save_after` when the order state changes to canceled (e.g. when a payment gateway uses `registerCancellation()`). + +2. **Conditions**: Returned is only called when: + - The order state is canceled (entire order canceled, not partial). + - The order has no invoices (unpaid; refunds continue to use the credit memo flow). + - TaxCloud reports the order as captured: the extension calls TaxCloud's **OrderDetails** API and only calls Returned when the response includes a non-empty **CapturedDate**. This uses TaxCloud as the source of truth and works for existing orders captured before the extension was installed or after database migrations. + +3. **TaxCloud API call**: The extension calls the `Returned` API for the full order (all items and shipping), using the same `taxcloud_returned_before` and `taxcloud_returned_after` events (with `creditmemo` null for cancellation). + +4. **Refunds unchanged**: Orders that have been invoiced and then refunded via credit memo are not affected; they continue to use the refund flow described above. + +**Note:** This behavior applies to all merchants. It is especially relevant if you use offline or deferred payment methods where orders can be created and later canceled before payment. + ## Extending the TaxCloud Module In certain cases, a store owner may need to extend this module. Specific use cases might include: needing to adjust the shipping cost for a shipment containing both taxable and non-taxable items, fetching exemption certificates from an external source, or changing the shipping origin for multi-warehouse fulfillment. @@ -226,9 +245,10 @@ Each of these situations can be accomplished using an event observer. For every | `taxcloud_verify_address_after` | Emitted after the `VerifyAddress` call during checkout | `$result` | | `taxcloud_authorized_with_capture_before` | Emitted before the `AuthorizedWithCapture` call when an order is placed | `$params`, `$order` | | `taxcloud_authorized_with_capture_after` | Emitted after the `AuthorizedWithCapture` call when an order is placed | `$result`, `$order` | -| `taxcloud_returned_before` | Emitted before the `Returned` call when a credit memo is created | `$params`, `$order`, `$items`, `$creditmemo` | -| `taxcloud_returned_after` | Emitted after the `Returned` call when a credit memo is created | `$result`, `$order`, `$items`, `$creditmemo` | +| `taxcloud_returned_before` | Emitted before the `Returned` call when a credit memo is created or when a canceled unpaid order is reversed | `$params`, `$order`, `$items`, `$creditmemo` | +| `taxcloud_returned_after` | Emitted after the `Returned` call when a credit memo is created or when a canceled unpaid order is reversed | `$result`, `$order`, `$items`, `$creditmemo` | +For order cancellation, `$creditmemo` is null and `$items` are the order items. ## Automated Deployment diff --git a/Test/Unit/Mocks/MagentoMocks.php b/Test/Unit/Mocks/MagentoMocks.php index 9353a0f..9ec7806 100644 --- a/Test/Unit/Mocks/MagentoMocks.php +++ b/Test/Unit/Mocks/MagentoMocks.php @@ -62,6 +62,40 @@ public function getTaxClassId() { return null; } } } +// Mock Magento Sales API +namespace Magento\Sales\Api { + interface OrderRepositoryInterface + { + public function save($order); + public function get($id); + } +} + +// Mock Magento Sales Model (main Order used by returnOrderCancellation and Cancel) +namespace Magento\Sales\Model\ResourceModel\Order\Invoice { + class Collection + { + public function getSize() { return 0; } + } +} + +namespace Magento\Sales\Model { + class Order + { + const STATE_CANCELED = 'canceled'; + + public function getId() { return null; } + public function getIncrementId() { return null; } + public function getState() { return null; } + public function getData($key = null) { return null; } + public function setData($key, $value = null) { return $this; } + public function getAllVisibleItems() { return []; } + public function getBaseShippingAmount() { return 0; } + public function getBaseTaxAmount() { return 0; } + public function getInvoiceCollection() { return new \Magento\Sales\Model\ResourceModel\Order\Invoice\Collection(); } + } +} + // Mock Magento Sales Classes namespace Magento\Sales\Model\Order { class Item @@ -70,6 +104,9 @@ public function getSku() { return null; } public function setSku($sku) { return $this; } public function getProduct() { return null; } public function setProduct($product) { return $this; } + public function getQtyOrdered() { return 0; } + public function getPrice() { return 0; } + public function getDiscountAmount() { return 0; } } class Order @@ -77,7 +114,9 @@ class Order public function getIncrementId() { return null; } public function setIncrementId($id) { return $this; } public function getAllItems() { return []; } + public function getAllVisibleItems() { return []; } public function setItems($items) { return $this; } + public function getBaseTaxAmount() { return 0; } } class Creditmemo @@ -88,6 +127,8 @@ public function getAllItems() { return []; } public function setItems($items) { return $this; } public function getShippingAmount() { return 0; } public function setShippingAmount($amount) { return $this; } + public function getBaseGrandTotal() { return 0; } + public function getBaseTaxAmount() { return 0; } } } @@ -155,13 +196,35 @@ class LocalizedException extends \Exception { } class NoSuchEntityException extends \Exception { } } +// Mock Magento Framework Event +namespace Magento\Framework { + class Event + { + public function getName() { return ''; } + public function getOrder() { return null; } + } +} + // Mock Magento Framework Event Classes namespace Magento\Framework\Event { + interface ObserverInterface + { + public function execute(\Magento\Framework\Event\Observer $observer); + } + + class Observer + { + protected $event; + + public function getEvent() { return $this->event; } + public function setEvent($event) { $this->event = $event; return $this; } + } + interface ManagerInterface { public function dispatch($eventName, array $data = []); } - + class Manager implements ManagerInterface { public function dispatch($eventName, array $data = []) { /* do nothing */ } diff --git a/Test/Unit/Model/ApiTest.php b/Test/Unit/Model/ApiTest.php index 98bb9b7..85ae9c6 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']) + ->addMethods(['Returned', 'lookup', 'authorizedWithCapture', 'OrderDetails']) ->getMock(); $this->mockDataObject = $this->getMockBuilder(DataObject::class) ->disableOriginalConstructor() @@ -84,6 +84,20 @@ protected function setUp(): void $this->cartItemResponseHandler, $this->productTicService ); + $this->injectMockSoapClientIntoApi(); + } + + /** + * Inject mock SoapClient into Api so getClient() returns it (Api uses new \SoapClient in getClient). + */ + private function injectMockSoapClientIntoApi() + { + $ref = new \ReflectionClass(Api::class); + $prop = $ref->getProperty('client'); + if (method_exists($prop, 'setAccessible')) { + @$prop->setAccessible(true); + } + $prop->setValue($this->api, $this->mockSoapClient); } public function testReturnOrderIncludesReturnCoDeliveryFeeWhenNoCartItems() @@ -111,6 +125,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'); + $order->method('getBaseTaxAmount')->willReturn(0); $creditmemo->method('getOrder')->willReturn($order); $creditmemo->method('getAllItems')->willReturn([]); @@ -179,6 +194,8 @@ public function testReturnOrderTaxOnlyRefundCallsReturnedThenReCreatesAsExempt() $order = $this->createMock(\Magento\Sales\Model\Order\Order::class); $order->method('getIncrementId')->willReturn('TEST_ORDER_123'); $order->method('getBaseTaxAmount')->willReturn(5.0); + $order->method('getAllVisibleItems')->willReturn([]); + $order->method('getBaseShippingAmount')->willReturn(0); $creditmemo->method('getOrder')->willReturn($order); $creditmemo->method('getAllItems')->willReturn([]); @@ -243,7 +260,8 @@ 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'); - + + $order->method('getBaseTaxAmount')->willReturn(0); $creditmemo->method('getOrder')->willReturn($order); $creditmemo->method('getAllItems')->willReturn([]); $creditmemo->method('getShippingAmount')->willReturn(0); @@ -295,7 +313,7 @@ public function testReturnOrderWithCartItems() $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'); - + $creditItem = $this->createMock(\Magento\Sales\Model\Order\Creditmemo\Item::class); $orderItem = $this->createMock(\Magento\Sales\Model\Order\Item::class); $product = $this->createMock(\Magento\Catalog\Model\Product::class); @@ -316,7 +334,7 @@ public function testReturnOrderWithCartItems() $customAttribute->method('getValue')->willReturn('20000'); $this->productFactory->method('create')->willReturn($productModel); - + $creditmemo->method('getOrder')->willReturn($order); $creditmemo->method('getAllItems')->willReturn([$creditItem]); $creditmemo->method('getShippingAmount')->willReturn(5.99); @@ -398,7 +416,8 @@ 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'); - + + $order->method('getBaseTaxAmount')->willReturn(0); $creditmemo->method('getOrder')->willReturn($order); $creditmemo->method('getAllItems')->willReturn([]); $creditmemo->method('getShippingAmount')->willReturn(0); @@ -425,4 +444,188 @@ public function testReturnOrderFailsWhenParameterIsLost() // The test expects the method to return false due to the SOAP error $this->assertFalse($result, 'returnOrder should return false when returnCoDeliveryFeeWhenNoCartItems parameter is missing'); } + + /** + * returnOrderCancellation: success path with order items and shipping. + */ + public function testReturnOrderCancellationSuccess() + { + $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'] + ]); + + $this->objectFactory->method('create')->willReturn($this->mockDataObject); + $this->mockDataObject->method('setParams')->willReturnSelf(); + $this->mockDataObject->method('getParams')->willReturn([ + 'apiLoginID' => 'test_api_id', + 'apiKey' => 'test_api_key', + 'orderID' => 'CANCEL_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' => [] + ]); + + $orderItem = $this->createMock(\Magento\Sales\Model\Order\Item::class); + $orderItem->method('getQtyOrdered')->willReturn(1); + $orderItem->method('getPrice')->willReturn(10.00); + $orderItem->method('getDiscountAmount')->willReturn(0); + $orderItem->method('getSku')->willReturn('SKU1'); + + $this->productTicService->method('getProductTic')->with($orderItem, 'returnOrder')->willReturn('20000'); + $this->productTicService->method('getShippingTic')->willReturn('11010'); + + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getIncrementId')->willReturn('CANCEL_ORDER_123'); + $order->method('getAllVisibleItems')->willReturn([$orderItem]); + $order->method('getBaseShippingAmount')->willReturn(5.99); + + $mockResponse = new \stdClass(); + $mockResponse->ReturnedResult = new \stdClass(); + $mockResponse->ReturnedResult->ResponseType = 'OK'; + $mockResponse->ReturnedResult->Messages = []; + $this->mockSoapClient->method('Returned')->willReturn($mockResponse); + + $result = $this->api->returnOrderCancellation($order); + + $this->assertTrue($result, 'returnOrderCancellation should return true on success'); + } + + /** + * returnOrderCancellation: empty cart items returns false. + */ + public function testReturnOrderCancellationEmptyCartItemsReturnsFalse() + { + $this->scopeConfig->method('getValue') + ->willReturnMap([ + ['tax/taxcloud_settings/logging', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '1'] + ]); + + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getIncrementId')->willReturn('EMPTY_ORDER'); + $order->method('getAllVisibleItems')->willReturn([]); + $order->method('getBaseShippingAmount')->willReturn(0); + + $result = $this->api->returnOrderCancellation($order); + + $this->assertFalse($result, 'returnOrderCancellation should return false when order has no cart items'); + } + + /** + * returnOrderCancellation: SOAP error returns false. + */ + public function testReturnOrderCancellationSoapErrorReturnsFalse() + { + $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'] + ]); + + $this->objectFactory->method('create')->willReturn($this->mockDataObject); + $this->mockDataObject->method('setParams')->willReturnSelf(); + $this->mockDataObject->method('getParams')->willReturn([ + 'apiLoginID' => 'test_api_id', + 'apiKey' => 'test_api_key', + 'orderID' => 'CANCEL_ORDER_123', + 'cartItems' => [['ItemID' => 'SKU1', 'Index' => 0, 'TIC' => '20000', 'Price' => 10, 'Qty' => 1]], + 'returnedDate' => '2026-01-03T00:00:00+00:00', + 'returnCoDeliveryFeeWhenNoCartItems' => false + ]); + + $orderItem = $this->createMock(\Magento\Sales\Model\Order\Item::class); + $orderItem->method('getQtyOrdered')->willReturn(1); + $orderItem->method('getPrice')->willReturn(10.00); + $orderItem->method('getDiscountAmount')->willReturn(0); + $orderItem->method('getSku')->willReturn('SKU1'); + $this->productTicService->method('getProductTic')->willReturn('20000'); + $this->productTicService->method('getShippingTic')->willReturn('11010'); + + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getIncrementId')->willReturn('CANCEL_ORDER_123'); + $order->method('getAllVisibleItems')->willReturn([$orderItem]); + $order->method('getBaseShippingAmount')->willReturn(0); + + $this->mockSoapClient->method('Returned') + ->willThrowException(new \SoapFault('SOAP-ERROR', 'Server error')); + + $result = $this->api->returnOrderCancellation($order); + $this->assertFalse($result, 'returnOrderCancellation should return false when SOAP call fails'); + } + + /** + * getOrderDetails: success path returns OrderDetailsResult array with CapturedDate. + */ + public function testGetOrderDetailsReturnsResultWhenCaptured() + { + $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'], + ]); + + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getIncrementId')->willReturn('ORDER_100'); + + $mockResponse = new \stdClass(); + $mockResponse->OrderDetailsResult = new \stdClass(); + $mockResponse->OrderDetailsResult->ResponseType = 'OK'; + $mockResponse->OrderDetailsResult->CapturedDate = '2024-01-15T12:00:00'; + + $this->mockSoapClient->method('OrderDetails') + ->with($this->callback(function ($params) { + return isset($params['apiLoginID'], $params['apiKey'], $params['orderID']) + && $params['orderID'] === 'ORDER_100'; + })) + ->willReturn($mockResponse); + + $result = $this->api->getOrderDetails($order); + + $this->assertIsArray($result, 'getOrderDetails should return array on success'); + $this->assertSame('OK', $result['ResponseType']); + $this->assertSame('2024-01-15T12:00:00', $result['CapturedDate']); + } + + /** + * getOrderDetails: returns null when ResponseType is not OK or order not found. + */ + public function testGetOrderDetailsReturnsNullWhenNotOkOrError() + { + $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'], + ]); + + $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order->method('getIncrementId')->willReturn('ORDER_101'); + + $mockResponse = new \stdClass(); + $mockResponse->OrderDetailsResult = new \stdClass(); + $mockResponse->OrderDetailsResult->ResponseType = 'Error'; + + $this->mockSoapClient->method('OrderDetails')->willReturn($mockResponse); + + $result = $this->api->getOrderDetails($order); + + $this->assertNull($result, 'getOrderDetails should return null when ResponseType is not OK'); + } } \ No newline at end of file diff --git a/Test/Unit/Observer/Sales/CancelTest.php b/Test/Unit/Observer/Sales/CancelTest.php new file mode 100644 index 0000000..24e257e --- /dev/null +++ b/Test/Unit/Observer/Sales/CancelTest.php @@ -0,0 +1,190 @@ +createMock(\Magento\Framework\App\Config\ScopeConfigInterface::class); + $scopeConfig->method('getValue') + ->willReturnMap([ + ['tax/taxcloud_settings/enabled', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '0'], + ['tax/taxcloud_settings/logging', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '0'] + ]); + + $tcapi = $this->createMock(\Taxcloud\Magento2\Model\Api::class); + $tcapi->expects($this->never())->method('returnOrderCancellation'); + + $logger = $this->createMock(\Taxcloud\Magento2\Logger\Logger::class); + + $observer = new Cancel($scopeConfig, $tcapi, $logger); + + $event = $this->createMock(\Magento\Framework\Event::class); + $event->method('getName')->willReturn('order_cancel_after'); + $order = $this->createMock(Order::class); + $order->method('getId')->willReturn(1); + $event->method('getOrder')->willReturn($order); + + $observerObj = $this->createMock(\Magento\Framework\Event\Observer::class); + $observerObj->method('getEvent')->willReturn($event); + + $observer->execute($observerObj); + } + + /** + * When order has invoices, returnOrderCancellation is not called. + */ + public function testProcessOrderCancelSkipsWhenOrderHasInvoices() + { + $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/logging', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '0'] + ]); + + $tcapi = $this->createMock(\Taxcloud\Magento2\Model\Api::class); + $tcapi->expects($this->never())->method('returnOrderCancellation'); + + $logger = $this->createMock(\Taxcloud\Magento2\Logger\Logger::class); + + $observer = new Cancel($scopeConfig, $tcapi, $logger); + + $order = $this->createMock(Order::class); + $order->method('getIncrementId')->willReturn('10001'); + $order->method('getState')->willReturn(Order::STATE_CANCELED); + $invoiceCollection = $this->createMock(\Magento\Sales\Model\ResourceModel\Order\Invoice\Collection::class); + $invoiceCollection->method('getSize')->willReturn(1); + $order->method('getInvoiceCollection')->willReturn($invoiceCollection); + + $event = $this->createMock(\Magento\Framework\Event::class); + $event->method('getName')->willReturn('order_cancel_after'); + $event->method('getOrder')->willReturn($order); + + $observerObj = $this->createMock(\Magento\Framework\Event\Observer::class); + $observerObj->method('getEvent')->willReturn($event); + + $observer->execute($observerObj); + } + + /** + * When order was not captured in TaxCloud (OrderDetails has no CapturedDate), returnOrderCancellation is not called. + */ + public function testProcessOrderCancelSkipsWhenNotTaxcloudCaptured() + { + $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/logging', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '0'] + ]); + + $tcapi = $this->createMock(\Taxcloud\Magento2\Model\Api::class); + $tcapi->expects($this->never())->method('returnOrderCancellation'); + + $logger = $this->createMock(\Taxcloud\Magento2\Logger\Logger::class); + + $observer = new Cancel($scopeConfig, $tcapi, $logger); + + $order = $this->createMock(Order::class); + $order->method('getIncrementId')->willReturn('10002'); + $order->method('getState')->willReturn(Order::STATE_CANCELED); + $invoiceCollection = $this->createMock(\Magento\Sales\Model\ResourceModel\Order\Invoice\Collection::class); + $invoiceCollection->method('getSize')->willReturn(0); + $order->method('getInvoiceCollection')->willReturn($invoiceCollection); + $tcapi->method('getOrderDetails')->with($order)->willReturn(null); + + $event = $this->createMock(\Magento\Framework\Event::class); + $event->method('getName')->willReturn('order_cancel_after'); + $event->method('getOrder')->willReturn($order); + + $observerObj = $this->createMock(\Magento\Framework\Event\Observer::class); + $observerObj->method('getEvent')->willReturn($event); + + $observer->execute($observerObj); + } + + /** + * When OrderDetails returns result but CapturedDate is empty, returnOrderCancellation is not called. + */ + public function testProcessOrderCancelSkipsWhenCapturedDateEmpty() + { + $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/logging', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '0'] + ]); + + $tcapi = $this->createMock(\Taxcloud\Magento2\Model\Api::class); + $tcapi->expects($this->never())->method('returnOrderCancellation'); + $tcapi->method('getOrderDetails')->willReturn(['ResponseType' => 'OK', 'CapturedDate' => '']); + + $logger = $this->createMock(\Taxcloud\Magento2\Logger\Logger::class); + $observer = new Cancel($scopeConfig, $tcapi, $logger); + + $order = $this->createMock(Order::class); + $order->method('getIncrementId')->willReturn('10003'); + $order->method('getState')->willReturn(Order::STATE_CANCELED); + $invoiceCollection = $this->createMock(\Magento\Sales\Model\ResourceModel\Order\Invoice\Collection::class); + $invoiceCollection->method('getSize')->willReturn(0); + $order->method('getInvoiceCollection')->willReturn($invoiceCollection); + + $event = $this->createMock(\Magento\Framework\Event::class); + $event->method('getName')->willReturn('order_cancel_after'); + $event->method('getOrder')->willReturn($order); + $observerObj = $this->createMock(\Magento\Framework\Event\Observer::class); + $observerObj->method('getEvent')->willReturn($event); + + $observer->execute($observerObj); + } + + /** + * When all conditions are met (including TaxCloud OrderDetails shows CapturedDate), returnOrderCancellation is called. + */ + public function testProcessOrderCancelCallsReturnWhenConditionsMet() + { + $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/logging', \Magento\Store\Model\ScopeInterface::SCOPE_STORE, null, '0'] + ]); + + $tcapi = $this->createMock(\Taxcloud\Magento2\Model\Api::class); + $tcapi->expects($this->once())->method('returnOrderCancellation')->willReturn(true); + + $logger = $this->createMock(\Taxcloud\Magento2\Logger\Logger::class); + + $observer = new Cancel($scopeConfig, $tcapi, $logger); + + $order = $this->createMock(Order::class); + $order->method('getId')->willReturn(1); + $order->method('getIncrementId')->willReturn('10004'); + $order->method('getState')->willReturn(Order::STATE_CANCELED); + $invoiceCollection = $this->createMock(\Magento\Sales\Model\ResourceModel\Order\Invoice\Collection::class); + $invoiceCollection->method('getSize')->willReturn(0); + $order->method('getInvoiceCollection')->willReturn($invoiceCollection); + $tcapi->method('getOrderDetails')->with($order)->willReturn(['CapturedDate' => '2024-01-01']); + + $event = $this->createMock(\Magento\Framework\Event::class); + $event->method('getName')->willReturn('order_cancel_after'); + $event->method('getOrder')->willReturn($order); + + $observerObj = $this->createMock(\Magento\Framework\Event\Observer::class); + $observerObj->method('getEvent')->willReturn($event); + + $observer->execute($observerObj); + } +} diff --git a/etc/events.xml b/etc/events.xml index f72546e..978e28a 100644 --- a/etc/events.xml +++ b/etc/events.xml @@ -26,6 +26,14 @@ + + + + + + + + diff --git a/etc/module.xml b/etc/module.xml index cff9483..f3e8a18 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -18,5 +18,8 @@ --> + + +