diff --git a/src/Plugin.php b/src/Plugin.php index 07b1f4c6fe..7e3df31d81 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -121,7 +121,7 @@ public static function t($message, $params = [], $language = null) * @inheritDoc */ - public $schemaVersion = '3.1.11'; + public $schemaVersion = '3.1.12'; /** * @inheritdoc @@ -362,6 +362,9 @@ private function _registerPermissions() 'commerce-capturePayment' => [ 'label' => self::t('Capture payment') ], + 'commerce-voidPayment' => [ + 'label' => self::t('Void payment') + ], 'commerce-refundPayment' => [ 'label' => self::t('Refund payment') ], diff --git a/src/controllers/OrdersController.php b/src/controllers/OrdersController.php index c95044efa5..54d3b9a861 100644 --- a/src/controllers/OrdersController.php +++ b/src/controllers/OrdersController.php @@ -786,6 +786,44 @@ public function actionTransactionCapture(): Response return $this->redirectToPostedUrl(); } + /** + * Voids Transaction + * + * @return Response + * @throws TransactionException + * @throws MissingComponentException + * @throws BadRequestHttpException + */ + public function actionTransactionVoid(): Response + { + $this->requirePermission('commerce-VoidPayment'); + $this->requirePostRequest(); + $id = Craft::$app->getRequest()->getRequiredBodyParam('id'); + $transaction = Plugin::getInstance()->getTransactions()->getTransactionById($id); + + if ($transaction->canVoid()) { + // void transaction and display result + $child = Plugin::getInstance()->getPayments()->voidTransaction($transaction); + + $message = $child->message ? ' (' . $child->message . ')' : ''; + + if ($child->status == TransactionRecord::STATUS_SUCCESS) { + $child->order->updateOrderPaidInformation(); + Craft::$app->getSession()->setNotice(Plugin::t('Transaction voided successfully: {message}', [ + 'message' => $message + ])); + } else { + Craft::$app->getSession()->setError(Plugin::t('Couldn’t void transaction: {message}', [ + 'message' => $message + ])); + } + } else { + Craft::$app->getSession()->setError(Plugin::t('Couldn’t void transaction.', ['id' => $id])); + } + + return $this->redirectToPostedUrl(); + } + /** * Refunds transaction. * @@ -1300,19 +1338,26 @@ private function _getTransactionsWIthLevelsTableArray($transactions, $level = 0) $user = Craft::$app->getUser()->getIdentity(); foreach ($transactions as $transaction) { if (!ArrayHelper::firstWhere($return, 'id', $transaction->id)) { - $refundCapture = ''; - if ($user->can('commerce-capturePayment') && $transaction->canCapture()) { - $refundCapture = Craft::$app->getView()->renderTemplate( - 'commerce/orders/includes/_capture', - [ - 'currentUser' => $user, - 'transaction' => $transaction, - ] - ); - } else if ($user->can('commerce-refundPayment') && $transaction->canRefund()) { - $refundCapture = Craft::$app->getView()->renderTemplate( - 'commerce/orders/includes/_refund', + $refundCaptureVoid = ''; + $actions = []; + + if ($user->can('commerce-capturePayment') && $canCapture = $transaction->canCapture()) { + $actions[] = 'capture'; + } + + if ($user->can('commerce-voidPayment') && $canVoid = $transaction->canVoid()) { + $actions[] = 'void'; + } + + if (!$canCapture && !$canVoid && $user->can('commerce-refundPayment') && $transaction->canRefund()) { + $actions[] = 'refund'; + } + + if (!empty($actions)) { + $refundCaptureVoid = Craft::$app->getView()->renderTemplate( + 'commerce/orders/includes/_refundCaptureVoid', [ + 'actions' => $actions, 'currentUser' => $user, 'transaction' => $transaction, ] @@ -1349,7 +1394,7 @@ private function _getTransactionsWIthLevelsTableArray($transactions, $level = 0) ['label' => Html::encode(Plugin::t('Converted Price')), 'type' => 'text', 'value' => Plugin::getInstance()->getPaymentCurrencies()->convert($transaction->paymentAmount, $transaction->paymentCurrency) . ' (' . $transaction->currency . ')' . ' (1 ' . $transaction->currency . ' = ' . number_format($transaction->paymentRate) . ' ' . $transaction->paymentCurrency . ')'], ['label' => Html::encode(Plugin::t('Gateway Response')), 'type' => 'response', 'value' => $transactionResponse], ], - 'actions' => $refundCapture, + 'actions' => $refundCaptureVoid, ]; if (!empty($transaction->childTransactions)) { diff --git a/src/migrations/m200528_201030_add_void.php b/src/migrations/m200528_201030_add_void.php new file mode 100644 index 0000000000..59665318c6 --- /dev/null +++ b/src/migrations/m200528_201030_add_void.php @@ -0,0 +1,43 @@ +db->getIsPgsql()) { + // Manually construct the SQL for Postgres + $check = '[[type]] in ('; + foreach ($values as $i => $value) { + if ($i != 0) { + $check .= ','; + } + $check .= $this->db->quoteValue($value); + } + $check .= ')'; + $this->execute("alter table {{%commerce_transactions}} drop constraint {{%commerce_transactions_type_check}}, add check ({$check})"); + } else { + $this->alterColumn('{{%commerce_transactions}}', 'type', $this->enum('type', $values)); + } + } + + /** + * @inheritdoc + */ + public function safeDown() + { + echo "m200528_201030_add_void cannot be reverted.\n"; + return false; + } +} diff --git a/src/models/Transaction.php b/src/models/Transaction.php index d9447a62d4..2b171e74f5 100644 --- a/src/models/Transaction.php +++ b/src/models/Transaction.php @@ -187,6 +187,14 @@ public function canCapture(): bool return Plugin::getInstance()->getTransactions()->canCaptureTransaction($this); } + /** + * @return bool + */ + public function canVoid(): bool + { + return Plugin::getInstance()->getTransactions()->canVoidTransaction($this); + } + /** * @return bool */ diff --git a/src/records/Transaction.php b/src/records/Transaction.php index ef3441cb49..3a43e959d8 100644 --- a/src/records/Transaction.php +++ b/src/records/Transaction.php @@ -39,6 +39,7 @@ class Transaction extends ActiveRecord { const TYPE_AUTHORIZE = 'authorize'; const TYPE_CAPTURE = 'capture'; + const TYPE_VOID = 'void'; const TYPE_PURCHASE = 'purchase'; const TYPE_REFUND = 'refund'; const STATUS_PENDING = 'pending'; diff --git a/src/services/Payments.php b/src/services/Payments.php index 9714edfca6..8b43a8fe59 100644 --- a/src/services/Payments.php +++ b/src/services/Payments.php @@ -109,6 +109,54 @@ class Payments extends Component */ const EVENT_AFTER_CAPTURE_TRANSACTION = 'afterCaptureTransaction'; + /** + * @event TransactionEvent The event that is triggered before a payment transaction is voided. + * + * ```php + * use craft\commerce\events\TransactionEvent; + * use craft\commerce\services\Payments; + * use craft\commerce\models\Transaction; + * use yii\base\Event; + * + * Event::on( + * Payments::class, + * Payments::EVENT_BEFORE_VOID_TRANSACTION, + * function(TransactionEvent $event) { + * // @var Transaction $transaction + * $transaction = $event->transaction; + * + * // Check that order isn't already processing + * // ... + * } + * ); + * ``` + */ + const EVENT_BEFORE_VOID_TRANSACTION = 'beforeVoidTransaction'; + + /** + * @event TransactionEvent The event that is triggered after a payment transaction is voided. + * + * ```php + * use craft\commerce\events\TransactionEvent; + * use craft\commerce\services\Payments; + * use craft\commerce\models\Transaction; + * use yii\base\Event; + * + * Event::on( + * Payments::class, + * Payments::EVENT_AFTER_VOID_TRANSACTION, + * function(TransactionEvent $event) { + * // @var Transaction $transaction + * $transaction = $event->transaction; + * + * // Notify customer their transaction has been voided + * // ... + * } + * ); + * ``` + */ + const EVENT_AFTER_VOID_TRANSACTION = 'afterVoidTransaction'; + /** * @event TransactionEvent The event that is triggered before a transaction is refunded. * @@ -346,6 +394,34 @@ public function captureTransaction(Transaction $transaction): Transaction return $transaction; } + /** + * Void a transaction. + * + * @param Transaction $transaction the transaction to void. + * @return Transaction + * @throws TransactionException if something went wrong when saving the transaction + */ + public function voidTransaction(Transaction $transaction): Transaction + { + // Raise 'beforeVoidTransaction' event + if ($this->hasEventHandlers(self::EVENT_BEFORE_VOID_TRANSACTION)) { + $this->trigger(self::EVENT_BEFORE_VOID_TRANSACTION, new TransactionEvent([ + 'transaction' => $transaction + ])); + } + + $transaction = $this->_void($transaction); + + // Raise 'afterVoidTransaction' event + if ($this->hasEventHandlers(self::EVENT_AFTER_VOID_TRANSACTION)) { + $this->trigger(self::EVENT_AFTER_VOID_TRANSACTION, new TransactionEvent([ + 'transaction' => $transaction + ])); + } + + return $transaction; + } + /** * Refund a transaction. * @@ -582,7 +658,7 @@ private function _handleRedirect(RequestResponseInterface $response, &$redirect) } /** - * Process a capture or refund exception. + * Process a capture transaction. * * @param Transaction $parent * @return Transaction @@ -609,7 +685,34 @@ private function _capture(Transaction $parent): Transaction } /** - * Process a capture or refund exception. + * Process a void transaction. + * + * @param Transaction $parent + * @return Transaction + * @throws TransactionException if unable to save transaction + */ + private function _void(Transaction $parent): Transaction + { + $child = Plugin::getInstance()->getTransactions()->createTransaction(null, $parent, TransactionRecord::TYPE_VOID); + + $gateway = $parent->getGateway(); + + try { + $response = $gateway->void($child, (string)$parent->reference); + $this->_updateTransaction($child, $response); + } catch (Exception $e) { + $child->status = TransactionRecord::STATUS_FAILED; + $child->message = $e->getMessage(); + $this->_saveTransaction($child); + + Craft::error($e->getMessage()); + } + + return $child; + } + + /** + * Process a refund transaction. * * @param Transaction $parent * @param float|null $amount diff --git a/src/services/Transactions.php b/src/services/Transactions.php index 1fd4ef610a..6b740b5b67 100644 --- a/src/services/Transactions.php +++ b/src/services/Transactions.php @@ -19,6 +19,8 @@ use craft\commerce\records\Transaction as TransactionRecord; use craft\db\Query; use yii\base\Component; +use DateTime; +use DateInterval; /** * Transaction service. @@ -43,7 +45,7 @@ class Transactions extends Component * function(TransactionEvent $event) { * // @var Transaction $transaction * $transaction = $event->transaction; - * + * * // Run custom logic for failed transactions * // ... * } @@ -60,14 +62,14 @@ class Transactions extends Component * use craft\commerce\services\Transactions; * use craft\commerce\models\Transaction; * use yii\base\Event; - * + * * Event::on( * Transactions::class, * Transactions::EVENT_AFTER_CREATE_TRANSACTION, * function(TransactionEvent $event) { * // @var Transaction $transaction * $transaction = $event->transaction; - * + * * // Run custom logic depending on the transaction type * // ... * } @@ -78,7 +80,7 @@ class Transactions extends Component /** - * Returns true if a specific transaction can be refunded. + * Returns true if a specific transaction can be captured. * * @param Transaction $transaction the transaction * @return bool @@ -100,10 +102,10 @@ public function canCaptureTransaction(Transaction $transaction): bool return false; } - // And only if we don't have a successful refund transaction for this order already + // And only if we don't have a successful capture or void transaction for this transaction already return !$this->_createTransactionQuery() ->where([ - 'type' => TransactionRecord::TYPE_CAPTURE, + 'type' => [TransactionRecord::TYPE_CAPTURE, TransactionRecord::TYPE_VOID], 'status' => TransactionRecord::STATUS_SUCCESS, 'orderId' => $transaction->orderId, 'parentId' => $transaction->id @@ -111,6 +113,49 @@ public function canCaptureTransaction(Transaction $transaction): bool ->exists(); } + /** + * Returns true if a specific transaction can be voided. + * + * @param Transaction $transaction the transaction + * @return bool + */ + public function canVoidTransaction(Transaction $transaction): bool + { + // Can only void successful authorize, capture, or purchase transactions + $types = [TransactionRecord::TYPE_AUTHORIZE, TransactionRecord::TYPE_CAPTURE, TransactionRecord::TYPE_PURCHASE]; + if (!in_array($transaction->type, $types) || $transaction->status !== TransactionRecord::STATUS_SUCCESS) { + return false; + } + + $gateway = $transaction->getGateway(); + + if (!$gateway) { + return false; + } + + if (!$gateway->supportsVoid()) { + return false; + } + + if ($transaction->type === TransactionRecord::TYPE_AUTHORIZE) { + // And only if we don't have a successful capture or void transaction for this transaction already + return !$this->_createTransactionQuery() + ->where([ + 'type' => [TransactionRecord::TYPE_CAPTURE, TransactionRecord::TYPE_VOID], + 'status' => TransactionRecord::STATUS_SUCCESS, + 'orderId' => $transaction->orderId, + 'parentId' => $transaction->id + ]) + ->exists(); + } else { + // Only if current time is within gateway void window + $now = new DateTime(); + $interval = new DateInterval("PT{$gateway->voidWindow}S"); + $edge = $transaction->dateCreated->add($interval); + return ($now < $edge); + } + } + /** * Returns true if a specific transaction can be refunded. * @@ -138,6 +183,16 @@ public function canRefundTransaction(Transaction $transaction): bool return false; } + if ($gateway->supportsVoid()) { + // Only if current time is outside gateway void window + $now = new DateTime(); + $interval = new DateInterval("PT{$gateway->voidWindow}S"); + $edge = $transaction->dateCreated->add($interval); + if ($now >= $edge) { + return false; + } + } + return ($this->refundableAmountForTransaction($transaction) > 0); } diff --git a/src/templates/orders/includes/_capture.html b/src/templates/orders/includes/_capture.html deleted file mode 100644 index 1b718a450b..0000000000 --- a/src/templates/orders/includes/_capture.html +++ /dev/null @@ -1,11 +0,0 @@ -{% if currentUser.can('commerce-capturePayment') and transaction.canCapture() %} -
- {{ csrfInput() }} - {{ 'Capture'|t('commerce') }} -
-{% endif %} \ No newline at end of file diff --git a/src/templates/orders/includes/_refund.html b/src/templates/orders/includes/_refund.html deleted file mode 100644 index 03942544a9..0000000000 --- a/src/templates/orders/includes/_refund.html +++ /dev/null @@ -1,25 +0,0 @@ -{% if currentUser.can('commerce-refundPayment') and transaction.canRefund() %} -
- {{ csrfInput() }} - {% import "_includes/forms" as forms %} - {{ forms.text({ - id: 'amount', - size: 10, - name: 'amount', - placeholder: transaction.paymentCurrency~' '~transaction.refundableAmount - }) }} - {{ forms.text({ - id: 'note', - size: 20, - name: 'note', - value: transaction.note, - placeholder: 'Refund Note' - }) }} - {{ 'Refund'|t('commerce') }} -
-{% endif %} diff --git a/src/templates/orders/includes/_refundCaptureVoid.html b/src/templates/orders/includes/_refundCaptureVoid.html new file mode 100644 index 0000000000..66d71acfd4 --- /dev/null +++ b/src/templates/orders/includes/_refundCaptureVoid.html @@ -0,0 +1,43 @@ +{% if actions %} +
+ {{ csrfInput() }} + {% if 'capture' in actions and currentUser.can('commerce-capturePayment') and transaction.canCapture() %} + {{ 'Capture'|t('commerce') }} + {% endif %} + {% if 'void' in actions and currentUser.can('commerce-voidPayment') and transaction.canVoid() %} + {{ 'Void'|t('commerce') }} + {% endif %} + {% if 'refund' in actions and currentUser.can('commerce-refundPayment') and transaction.canRefund() %} + {% import "_includes/forms" as forms %} + {{ forms.text({ + id: 'amount', + size: 10, + name: 'amount', + placeholder: transaction.paymentCurrency~' '~transaction.refundableAmount + }) }} + {{ forms.text({ + id: 'note', + size: 20, + name: 'note', + value: transaction.note, + placeholder: 'Refund Note' + }) }} + {{ 'Refund'|t('commerce') }} + {% endif %} +
+{% endif %}