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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions Model/Api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
154 changes: 154 additions & 0 deletions Observer/Sales/Cancel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<?php
/**
* Taxcloud_Magento2
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.txt.
* It is also available through the world-wide-web at this URL:
* http://opensource.org/licenses/osl-3.0.php
*
* @package Taxcloud_Magento2
* @author TaxCloud <service@taxcloud.net>
* @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()
);
}
}
}
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
Loading
Loading