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 @@
-->
+
+
+