Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a2a6dd05c | ||
|
|
da0f0d783a | ||
|
|
1997db3e1a | ||
|
|
662755d4c2 | ||
|
|
3619c683c3 | ||
|
|
7c93bdd066 | ||
|
|
1404ddfb81 | ||
|
|
aeabf078bb | ||
|
|
1b7024ffc0 | ||
|
|
1d9b841a64 | ||
|
|
4cf2e9ca99 | ||
|
|
44d512cc45 | ||
|
|
1260b5d01d | ||
|
|
0329963349 | ||
|
|
85b7a6a46e |
94
README.md
94
README.md
@ -1,73 +1,61 @@
|
||||
# Commerce BTCPay
|
||||
# Accept Bitcoin on your Drupal Commerce Store using BTCPay Server
|
||||
|
||||
This module provides a [Drupal Commerce 2.x](https://www.drupal.org/project/commerce) payment plugin for [BTCPay Server](https://docs.btcpayserver.org). This allows you to accept cryptocurrencies without a 3rd party intermediary by becoming your own payment processor.
|
||||
Introducing BTCPay Server payment module for [Drupal Commerce 2.x or 3.x](https://www.drupal.org/project/commerce). Drupal Commerce Store owners can now accept payments using Bitcoin and other cryptocurrencies directly through BTCPay Server without any third-party intermediary.
|
||||
|
||||
Visit the [project page on Drupal.org](https://drupal.org/project/commerce_btcpay)
|
||||
BTCPay Server supports a wide range of cryptocurrencies, with the potential for future extensions. Here are the currencies you can use now on BTCPay Server:
|
||||
|
||||
## Demo store
|
||||
A Drupal Commerce demo store connected with a (testnet) BTCPay Server where you can try the checkout (Bitcoin + Lightning Network) can be found here:
|
||||
[http://drupal.demo.btcpay.tech](http://drupal.demo.btcpay.tech/)
|
||||
- BTC (Bitcoin)
|
||||
- Bitcoin layer-two network (the Lightning Network) for fast and zero/low-fee transactions
|
||||
- Altcoins with full node integration including coins like Monero (XMR) and Litecoin (LTC).
|
||||
- Other Major Altcoins: Supported through plugins via platforms such as [Trocador](https://docs.btcpayserver.org/Trocador/), [SideShift](https://docs.btcpayserver.org/SideShift/), and FixedFloat.
|
||||
|
||||
Want to accept Bitcoin on your Drupal Commerce store? Visit the [project page on Drupal.org](https://drupal.org/project/commerce_btcpay)
|
||||
|
||||
## Requirements
|
||||
|
||||
* BTCPay Server ([self hosted or 3rd party](https://docs.btcpayserver.org/deployment/deployment) or [quick start with a testserver](https://docs.btcpayserver.org/btcpay-basics/tryitout))
|
||||
* Drupal Commerce 2.x installed ([installation guide](https://docs.drupalcommerce.org/commerce2/developer-guide/install-update/installation))
|
||||
* Drupal: [configured and writable private file system](https://www.drupal.org/docs/8/core/modules/file/overview#content-accessing-private-files)
|
||||
* Drupal Commerce 2.x or 3.x installed ([installation guide](https://docs.drupalcommerce.org/commerce2/developer-guide/install-update/installation))
|
||||
|
||||
## Installation and configuration
|
||||
## Upgrading
|
||||
|
||||
Short walkthrough screencast on installing and configuring the module:
|
||||
https://youtu.be/XBZwyC2v48s
|
||||
As of version 3.x of this module it uses the BTCPay Server Greenfield API which is much more powerful and allows more features. Version 3.x is a breaking change and also new libraries and API tokens are used. So please uninstall old versions (1.x and 2.x) before you install the latest 3.x release. And follow the setup instructions below.
|
||||
|
||||
### Quick walkthrough steps
|
||||
|
||||
#### Generate pairing code on BTCPay server
|
||||
1. BTCPay server: [create and configure a store](https://docs.btcpayserver.org/btcpay-basics/gettingstarted#creating-btcpay-store)
|
||||
2. in store settings go to "**Access Tokens**"
|
||||
3. click on **[Create a new token]**
|
||||
4. **Label:** enter some label (eg. my store)
|
||||
5. **Public key:** this needs to be left **empty**
|
||||
6. **Facade:** "merchant"
|
||||
7. click on **[Request pairing]**
|
||||
8. on next screen choose your configured store in** Pair to** select dropdown and click on **[approve]**
|
||||
9. note down the displayed 7-digit code at the top status message, e.g. "d7afaXr"
|
||||
(you will need that code below on gateway configuration, see below)
|
||||
## Installation and configuration Guide for the BTCPay Server - Drupal Commerce Integration
|
||||
|
||||
Ready to accept Bitcoin on your Drupal Commerce Store? Follow this quick and easy guide to install and configure the BTCPay Drupal Commerce module. For a quick run through, check out our installation and configuration screencast:
|
||||
|
||||
[](https://www.youtube.com/watch?v=BfzYYHR_bwU)
|
||||
|
||||
|
||||
### Easy setup steps
|
||||
|
||||
#### Prepare your BTCPay server store
|
||||
1. **Setup your store:** You'd need a BTCPay server instance to get started. Don't have one? click [here](https://docs.btcpayserver.org/RegisterAccount/) for a step-by-step guide.
|
||||
2. Make sure you have setup at least Bitcoin or Lightning wallet.
|
||||
|
||||
#### Commerce BTCPay: Installation + configuration
|
||||
1. install module: `composer require drupal/commerce_btcpay`
|
||||
2. enable the module: `drush en commerce_btcpay -y`
|
||||
3. make sure you have configured [private file system](https://www.drupal.org/docs/8/core/modules/file/overview#content-accessing-private-files) (needed to store encrypted public+private key)
|
||||
4. Commerce BTCPay configuration (**Commerce -> Configuration -> Payment -> Payment gateways**):
|
||||
5. add payment method "BTCPay"
|
||||
* **Mode**: Test or Live (you can configure both individually)
|
||||
* **Test/Live server host**: enter your URL without https:// prefix e.g. btcpay.yourserver.com (note valid SSL certificate needed)
|
||||
* **Test/Live Paring code**: enter the 7-digit pairing code from BTCPay "Access tokens" page
|
||||
* **Save**
|
||||
You should see a message that the tokens were successfully created.
|
||||
1. Install module: `composer require drupal/commerce_btcpay`
|
||||
2. Enable the module: `drush en commerce_btcpay -y`
|
||||
3. Go to Commerce BTCPay configuration (**Commerce -> Configuration -> Payment -> Payment gateways**):
|
||||
5. Click on **[Add payment gateway]**
|
||||
6. Enter the BTCPay Server URL (e.g. https://btcpay.yourdomain.tld). (This is where you created your store, see requirements for how to setup a BTCPay Server.)
|
||||
7. You can now click **Generate API Key** and you will get redirected to BTCPay Server authorization page.
|
||||
8. Select the store you want to connect to and click **[Continue]**
|
||||
9. On the next screen enter a label e.g. "Drupal 11 store".
|
||||
10. At the bottom click **[Authorize app]**
|
||||
11. You will get redirected to your Drupal Commerce store and you should see that the store id, API key and webhook was saved.
|
||||
12. Done, you can now test the payment gateway.
|
||||
|
||||
## Status
|
||||
**This module is currently in alpha stage but has been working for a while without issues.**
|
||||
Releases will be made available through the project page on drupal.org https://drupal.org/project/commerce_btcpay
|
||||
**This module is currently in alpha stage but has proven stable without issues.**
|
||||
Future updates and releases will be available on the [project page on drupal.org](https://drupal.org/project/commerce_btcpay)
|
||||
|
||||
## About BTCPay Server
|
||||
Short excerpt from [their project page](https://github.com/btcpayserver/btcpayserver):
|
||||
>BTCPay Server is a self-hosted, open-source cryptocurrency payment processor. It's secure, private, censorship-resistant and free.
|
||||
|
||||
**To get a full overview check out our [documentation](https://docs.btcpayserver.org).**
|
||||
|
||||
|
||||
## Supported cryptocurrencies
|
||||
BTCPay supports a vast variety of cryptocurrencies:
|
||||
- BTC (Bitcoin)
|
||||
- BTC 2nd layer [Lightning Network](https://bitcoiner.guide/lightning/) for instant settled low fee transactions
|
||||
- some altcoins using full node integration (XMR, LTC, ...)
|
||||
- and most other major altcoins through plugins via Trocador, SideShift and FixedFloat
|
||||
|
||||
## Compatible with BitPay API
|
||||
BTCPay was created to be a alternative to 3rd party payment provider [BitPay](https://bitpay.com). Therefore BTCPay is invoice API compatible and you can use this payment plugin also with the official BitPay API and sites if you want. But the power of BTCPay is that you can become your own payment provider.
|
||||
|
||||
Teaser, future versions of this plugin will be based on the BTCPay Server Greenfield API which is much more powerful and allows more features.
|
||||
>[BTCPay Server](https://btcpayserver.org/) is a self-hosted, open-source cryptocurrency payment processor know for its security, privacy, and censorship resistance.
|
||||
|
||||
It's free to use and allows you to become your own payment processor.
|
||||
**To get a full overview about BTCPay Server, check out our [documentation](https://docs.btcpayserver.org).**
|
||||
|
||||
## Get Support
|
||||
You can open an issue on our [Github repository](https://github.com/btcpayserver/commerce_btcpay/issues) or join us on [Telegram](https://t.me/btcpayserver) or [Mattermost chat](http://chat.btcpayserver.org/)
|
||||
You can open an issue on our [Github repository](https://github.com/btcpayserver/commerce_btcpay/issues) or reach us on [Telegram](https://t.me/btcpayserver) or [Mattermost chat](http://chat.btcpayserver.org/)
|
||||
|
||||
@ -2,7 +2,7 @@ name: Commerce BTCPay (Bitcoin and altcoin payments)
|
||||
type: module
|
||||
description: 'Provides payment gateway for use of BTCPay Server which also includes Lightning Network payments. BTCPay Server prodvides a BitPay compatible API which means this module also supports BitPay.'
|
||||
package: Commerce
|
||||
core_version_requirement: ^9 || ^10
|
||||
core_version_requirement: ^10 || ^11
|
||||
dependencies:
|
||||
- commerce:commerce_checkout
|
||||
- commerce:commerce_payment
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Install, update and uninstall functions for the Commerce BTCPay module.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Implements hook_requirements().
|
||||
*/
|
||||
function commerce_btcpay_requirements($phase) {
|
||||
$requirements = [];
|
||||
if ($phase == 'install') {
|
||||
if (!class_exists('\Bitpay\Client\Client')) {
|
||||
if (!class_exists('\BTCPayServer\Client\Store')) {
|
||||
$requirements['commerce_btcpay_requirement'] = [
|
||||
'title' => t('BitPay php-client library missing.'),
|
||||
'description' => t('Commerce BTCPay requires the BitPay php-client library: https://packagist.org/packages/bitpay/php-client which is installed automatically if you use Composer.'),
|
||||
'title' => t('BTCPay PHP library missing.'),
|
||||
'description' => t('Commerce BTCPay requires the BTCPay PHP client library: https://packagist.org/packages/btcpayserver/btcpayserver-greenfield-php which is installed automatically if you use Composer.'),
|
||||
'severity' => REQUIREMENT_ERROR,
|
||||
];
|
||||
}
|
||||
@ -17,3 +22,27 @@ function commerce_btcpay_requirements($phase) {
|
||||
|
||||
return $requirements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix payment gateway configuration for offsite payment gateways.
|
||||
*/
|
||||
function commerce_btcpay_update_8001() {
|
||||
$config_factory = \Drupal::configFactory();
|
||||
|
||||
// Load all payment gateway configurations.
|
||||
foreach ($config_factory->listAll('commerce_payment.commerce_payment_gateway.') as $config_name) {
|
||||
$config = $config_factory->getEditable($config_name);
|
||||
|
||||
// Check if this is a BTCPay gateway.
|
||||
if ($config->get('plugin') === 'btcpay_redirect') {
|
||||
// Update the configuration.
|
||||
$config->set('configuration.collect_billing_information', FALSE);
|
||||
$config->set('configuration.payment_method_types', []);
|
||||
$config->save();
|
||||
|
||||
\Drupal::messenger()->addMessage(t('Updated BTCPay payment gateway configuration: @name', [
|
||||
'@name' => $config->get('label'),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
commerce_btcpay.libraries.yml
Normal file
8
commerce_btcpay.libraries.yml
Normal file
@ -0,0 +1,8 @@
|
||||
api_key_redirect:
|
||||
version: 1.x
|
||||
js:
|
||||
js/api-key-redirect.js: {}
|
||||
dependencies:
|
||||
- core/drupal
|
||||
- core/drupalSettings
|
||||
- core/once
|
||||
16
commerce_btcpay.module
Normal file
16
commerce_btcpay.module
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains commerce_btcpay.module.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Implements hook_entity_type_alter().
|
||||
*/
|
||||
function commerce_btcpay_entity_type_alter(array &$entity_types) {
|
||||
// Use custom storage to fix payment gateway loading issue in Commerce core.
|
||||
if (isset($entity_types['commerce_payment'])) {
|
||||
$entity_types['commerce_payment']->setStorageClass('Drupal\commerce_btcpay\BtcPayPaymentStorage');
|
||||
}
|
||||
}
|
||||
23
commerce_btcpay.routing.yml
Normal file
23
commerce_btcpay.routing.yml
Normal file
@ -0,0 +1,23 @@
|
||||
commerce_btcpay.notify:
|
||||
path: '/payment/notify/{commerce_payment_gateway}'
|
||||
defaults:
|
||||
_controller: '\Drupal\commerce_payment\Controller\PaymentNotificationController::notifyPage'
|
||||
methods: [POST]
|
||||
requirements:
|
||||
_access: 'TRUE'
|
||||
|
||||
commerce_btcpay.api_key_callback:
|
||||
path: '/btcpay/api-key-callback'
|
||||
defaults:
|
||||
_controller: '\Drupal\commerce_btcpay\Controller\ApiKeyController::apiKeyCallback'
|
||||
methods: [GET, POST]
|
||||
requirements:
|
||||
_access: 'TRUE'
|
||||
|
||||
commerce_btcpay.api_key_success:
|
||||
path: '/btcpay/api-key-success'
|
||||
defaults:
|
||||
_controller: '\Drupal\commerce_btcpay\Controller\ApiKeyController::apiKeySuccess'
|
||||
_title: 'BTCPay API Key Authorization Success'
|
||||
requirements:
|
||||
_access: 'TRUE'
|
||||
@ -8,7 +8,7 @@
|
||||
"source": "https://github.com/btcpayserver/commerce_btcpay"
|
||||
},
|
||||
"require": {
|
||||
"ndeet/php-bitpay-client-legacy": "^5.0"
|
||||
"btcpayserver/btcpayserver-greenfield-php": "^2.8"
|
||||
},
|
||||
"minimum-stability": "dev"
|
||||
}
|
||||
|
||||
@ -1,15 +1,21 @@
|
||||
commerce_payment.commerce_payment_gateway.plugin.btcpay:
|
||||
commerce_payment.commerce_payment_gateway.plugin.btcpay_redirect:
|
||||
type: commerce_payment_gateway_configuration
|
||||
mapping:
|
||||
pairing_code_livenet:
|
||||
server_url:
|
||||
type: string
|
||||
label: "Livenet pairing code"
|
||||
pairing_code_testnet:
|
||||
label: "BTCPay Server URL"
|
||||
api_key:
|
||||
type: string
|
||||
label: "Testnet pairing code"
|
||||
server_livenet:
|
||||
label: "API Key"
|
||||
store_id:
|
||||
type: string
|
||||
label: "Livenet server URL"
|
||||
server_testnet:
|
||||
label: "Store ID"
|
||||
webhook_secret:
|
||||
type: string
|
||||
label: "Testnet server URL"
|
||||
label: "Webhook Secret"
|
||||
webhook_id:
|
||||
type: string
|
||||
label: "Webhook ID"
|
||||
debug_mode:
|
||||
type: boolean
|
||||
label: "Debug Mode"
|
||||
|
||||
93
js/api-key-redirect.js
Normal file
93
js/api-key-redirect.js
Normal file
@ -0,0 +1,93 @@
|
||||
(function (Drupal, drupalSettings, once) {
|
||||
'use strict';
|
||||
|
||||
// Required permissions for BTCPay API key
|
||||
const REQUIRED_PERMISSIONS = [
|
||||
'btcpay.store.canviewinvoices',
|
||||
'btcpay.store.cancreateinvoice',
|
||||
'btcpay.store.canviewstoresettings',
|
||||
'btcpay.store.canmodifyinvoices',
|
||||
'btcpay.store.webhooks.canmodifywebhooks'
|
||||
];
|
||||
|
||||
Drupal.behaviors.btcpayApiKeyRedirect = {
|
||||
attach: function (context, settings) {
|
||||
const buttons = once('btcpay-api-key', '.btcpay-generate-api-key', context);
|
||||
|
||||
buttons.forEach(function (button) {
|
||||
button.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
console.log('BTCPay Generate API Key button clicked');
|
||||
|
||||
const serverUrlField = document.querySelector('[name*="[server_url]"]');
|
||||
const serverUrl = serverUrlField ? serverUrlField.value.trim() : '';
|
||||
|
||||
console.log('Server URL:', serverUrl);
|
||||
|
||||
if (!isValidUrl(serverUrl)) {
|
||||
alert(Drupal.t('Please enter a valid URL including https:// in the BTCPay Server URL field.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate a unique token
|
||||
const token = 'btcpay_' + Date.now() + '_' + Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// Get the gateway ID from drupalSettings (passed from PHP) - optional for new gateways
|
||||
const gatewayId = drupalSettings.commerce_btcpay?.gateway_id || '';
|
||||
|
||||
console.log('Gateway ID:', gatewayId);
|
||||
|
||||
// Store server URL in sessionStorage so we can retrieve it after redirect
|
||||
sessionStorage.setItem('btcpay_temp_server_url', serverUrl);
|
||||
sessionStorage.setItem('btcpay_temp_token', token);
|
||||
if (gatewayId) {
|
||||
sessionStorage.setItem('btcpay_temp_gateway_id', gatewayId);
|
||||
}
|
||||
|
||||
// Build the callback URL with token, server_url, and optional gateway_id
|
||||
let callbackUrl = window.location.origin + drupalSettings.path.baseUrl + 'btcpay/api-key-callback?token=' + encodeURIComponent(token) + '&server_url=' + encodeURIComponent(serverUrl);
|
||||
if (gatewayId) {
|
||||
callbackUrl += '&gateway_id=' + encodeURIComponent(gatewayId);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
REQUIRED_PERMISSIONS.forEach(function(permission) {
|
||||
params.append('permissions', permission);
|
||||
});
|
||||
params.append('applicationName', 'Drupal Commerce');
|
||||
params.append('strict', 'true');
|
||||
params.append('selectiveStores', 'true');
|
||||
params.append('redirect', callbackUrl);
|
||||
|
||||
const authUrl = serverUrl.replace(/\/$/, '') + '/api-keys/authorize?' + params.toString();
|
||||
|
||||
console.log('Redirecting to BTCPay:', authUrl);
|
||||
|
||||
// Redirect to BTCPay Server for authorization
|
||||
window.location.href = authUrl;
|
||||
});
|
||||
});
|
||||
|
||||
function isValidUrl(serverUrl) {
|
||||
try {
|
||||
const url = new URL(serverUrl);
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
return false;
|
||||
}
|
||||
if (url.hostname.endsWith('.local')) {
|
||||
if (!confirm(Drupal.t('You entered a .local domain which only works on your local network. Please make sure your BTCPay Server is reachable on the internet if you want to use it in production. Continue anyway?'))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
})(Drupal, drupalSettings, once);
|
||||
52
src/BtcPayPaymentStorage.php
Normal file
52
src/BtcPayPaymentStorage.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\commerce_btcpay;
|
||||
|
||||
use Drupal\commerce_payment\PaymentStorage;
|
||||
|
||||
/**
|
||||
* Extends PaymentStorage to fix payment gateway loading issue.
|
||||
*/
|
||||
class BtcPayPaymentStorage extends PaymentStorage {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function doCreate(array $values) {
|
||||
// Populate the type using the payment gateway.
|
||||
if (!isset($values['type']) && !empty($values['payment_gateway'])) {
|
||||
$payment_gateway = $values['payment_gateway'];
|
||||
|
||||
// Load the payment gateway entity if it's a string ID.
|
||||
if (is_string($payment_gateway)) {
|
||||
$payment_gateway_storage = $this->entityTypeManager->getStorage('commerce_payment_gateway');
|
||||
$payment_gateway = $payment_gateway_storage->load($payment_gateway);
|
||||
$values['payment_gateway'] = $payment_gateway;
|
||||
}
|
||||
|
||||
// Get the payment type from the gateway plugin.
|
||||
if ($payment_gateway && is_object($payment_gateway)) {
|
||||
$plugin = $payment_gateway->getPlugin();
|
||||
if ($plugin) {
|
||||
$payment_type = $plugin->getPaymentType();
|
||||
if ($payment_type) {
|
||||
$values['type'] = $payment_type->getPluginId();
|
||||
}
|
||||
else {
|
||||
// Fallback to payment_default for offsite gateways.
|
||||
$values['type'] = 'payment_default';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure type is set.
|
||||
if (!isset($values['type'])) {
|
||||
$values['type'] = 'payment_default';
|
||||
}
|
||||
|
||||
// Call grandparent to skip parent's buggy implementation.
|
||||
return \Drupal\Core\Entity\ContentEntityStorageBase::doCreate($values);
|
||||
}
|
||||
|
||||
}
|
||||
384
src/Controller/ApiKeyController.php
Normal file
384
src/Controller/ApiKeyController.php
Normal file
@ -0,0 +1,384 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\commerce_btcpay\Controller;
|
||||
|
||||
use Drupal\Core\Controller\ControllerBase;
|
||||
use Drupal\Core\Url;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
|
||||
/**
|
||||
* Controller for BTCPay API key generation and authorization.
|
||||
*/
|
||||
class ApiKeyController extends ControllerBase {
|
||||
|
||||
/**
|
||||
* Required permissions for BTCPay API key.
|
||||
*/
|
||||
const REQUIRED_PERMISSIONS = [
|
||||
'btcpay.store.canviewinvoices',
|
||||
'btcpay.store.cancreateinvoice',
|
||||
'btcpay.store.canviewstoresettings',
|
||||
'btcpay.store.canmodifyinvoices',
|
||||
'btcpay.store.webhooks.canmodifywebhooks',
|
||||
];
|
||||
|
||||
/**
|
||||
* Handles the callback from BTCPay Server after authorization.
|
||||
*/
|
||||
public function apiKeyCallback(Request $request) {
|
||||
$api_key = $request->request->get('apiKey');
|
||||
$permissions = $request->request->all('permissions') ?: [];
|
||||
$token = $request->query->get('token');
|
||||
$gateway_id = $request->query->get('gateway_id');
|
||||
|
||||
// Validate token format
|
||||
if (empty($token) || !preg_match('/^btcpay_\d+_[a-z0-9]+$/', $token)) {
|
||||
return [
|
||||
'#markup' => $this->t('<h1>Invalid Token</h1><p>Invalid authorization token.</p>'),
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($api_key) || empty($permissions)) {
|
||||
return [
|
||||
'#markup' => $this->t('<h1>Invalid Authorization</h1><p>Invalid authorization response from BTCPay Server.</p>'),
|
||||
];
|
||||
}
|
||||
|
||||
// If gateway_id is provided, load the gateway and get server URL from it
|
||||
$server_url = '';
|
||||
$gateway = NULL;
|
||||
|
||||
if (!empty($gateway_id)) {
|
||||
$gateway_storage = \Drupal::entityTypeManager()->getStorage('commerce_payment_gateway');
|
||||
$gateway = $gateway_storage->load($gateway_id);
|
||||
|
||||
if ($gateway) {
|
||||
$configuration = $gateway->getPluginConfiguration();
|
||||
$server_url = $configuration['server_url'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
// If no server URL from gateway, try to get it from the request (stored by JS)
|
||||
if (empty($server_url)) {
|
||||
$server_url = $request->request->get('server_url') ?: $request->query->get('server_url');
|
||||
}
|
||||
|
||||
if (empty($server_url)) {
|
||||
return [
|
||||
'#markup' => $this->t('<h1>Missing Server URL</h1><p>Server URL not found. Please try again from the payment gateway configuration page.</p>'),
|
||||
];
|
||||
}
|
||||
|
||||
// Verify the API key works with the server URL
|
||||
if (!$this->verifyApiKey($server_url, $api_key)) {
|
||||
return [
|
||||
'#markup' => $this->t('<h1>Verification Failed</h1><p>Could not verify API key with BTCPay Server.</p>'),
|
||||
];
|
||||
}
|
||||
|
||||
// Validate permissions
|
||||
$validation = $this->validatePermissions($permissions);
|
||||
if (!$validation['valid']) {
|
||||
return [
|
||||
'#markup' => $this->t('<h1>Invalid Permissions</h1><p>@error</p>', ['@error' => $validation['error']]),
|
||||
];
|
||||
}
|
||||
|
||||
// Extract store ID from permissions
|
||||
$store_id = $this->extractStoreId($permissions);
|
||||
|
||||
// If we have a gateway, update it directly
|
||||
if ($gateway) {
|
||||
$configuration = $gateway->getPluginConfiguration();
|
||||
$configuration['api_key'] = $api_key;
|
||||
$configuration['store_id'] = $store_id;
|
||||
$gateway->setPluginConfiguration($configuration);
|
||||
$gateway->save();
|
||||
|
||||
$gateway_name = $gateway->label();
|
||||
|
||||
// Setup webhook
|
||||
$plugin = $gateway->getPlugin();
|
||||
$webhook_setup = FALSE;
|
||||
$webhook_message = '';
|
||||
if (method_exists($plugin, 'setupWebhook')) {
|
||||
try {
|
||||
// Use reflection to call the protected method
|
||||
$reflection = new \ReflectionClass($plugin);
|
||||
$method = $reflection->getMethod('setupWebhook');
|
||||
$method->setAccessible(TRUE);
|
||||
// Pass the gateway ID as parameter
|
||||
$webhook_setup = $method->invoke($plugin, $gateway_id);
|
||||
|
||||
if ($webhook_setup) {
|
||||
// Save the configuration again to store the webhook secret and webhook ID
|
||||
$gateway->setPluginConfiguration($plugin->getConfiguration());
|
||||
$gateway->save();
|
||||
$webhook_message = 'Webhook configured successfully.';
|
||||
}
|
||||
else {
|
||||
$webhook_message = 'Could not configure webhook automatically.';
|
||||
}
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
$webhook_message = 'Error setting up webhook: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Show success page for existing gateway
|
||||
$settings_url = Url::fromRoute('entity.commerce_payment_gateway.collection')->toString();
|
||||
|
||||
return [
|
||||
'#markup' => $this->t('
|
||||
<div style="max-width: 800px; margin: 50px auto; padding: 20px; font-family: sans-serif;">
|
||||
<h1 style="color: #28a745;">✓ API Key Generated and Saved Successfully!</h1>
|
||||
<p style="font-size: 16px; line-height: 1.6;">
|
||||
Your BTCPay Server has been authorized and the configuration has been saved.
|
||||
</p>
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||||
<p style="margin: 5px 0;"><strong>Gateway:</strong> @gateway_name</p>
|
||||
<p style="margin: 5px 0;"><strong>Server URL:</strong> @server_url</p>
|
||||
<p style="margin: 5px 0;"><strong>Store ID:</strong> @store_id</p>
|
||||
<p style="margin: 5px 0;"><strong>API Key:</strong> @api_key_preview</p>
|
||||
<p style="margin: 5px 0;"><strong>Webhook:</strong> @webhook_status</p>
|
||||
</div>
|
||||
<p style="margin-top: 30px;">
|
||||
<a href="@settings_url" style="display: inline-block; padding: 12px 24px; background: #007bff; color: white; text-decoration: none; border-radius: 4px; font-weight: bold;">
|
||||
Go to Payment Gateways →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
', [
|
||||
'@gateway_name' => $gateway_name,
|
||||
'@server_url' => $server_url,
|
||||
'@store_id' => $store_id,
|
||||
'@api_key_preview' => substr($api_key, 0, 20) . '...',
|
||||
'@webhook_status' => $webhook_message,
|
||||
'@settings_url' => $settings_url,
|
||||
]),
|
||||
];
|
||||
}
|
||||
else {
|
||||
// No gateway yet - create one automatically with a default name
|
||||
$gateway_storage = \Drupal::entityTypeManager()->getStorage('commerce_payment_gateway');
|
||||
|
||||
// Create new gateway
|
||||
$gateway = $gateway_storage->create([
|
||||
'id' => 'btcpay',
|
||||
'label' => 'BTCPay Server',
|
||||
'plugin' => 'btcpay_redirect',
|
||||
'configuration' => [
|
||||
'server_url' => $server_url,
|
||||
'api_key' => $api_key,
|
||||
'store_id' => $store_id,
|
||||
],
|
||||
'status' => TRUE,
|
||||
]);
|
||||
$gateway->save();
|
||||
|
||||
$gateway_id = $gateway->id();
|
||||
|
||||
// Setup webhook
|
||||
$plugin = $gateway->getPlugin();
|
||||
$webhook_setup = FALSE;
|
||||
$webhook_message = '';
|
||||
if (method_exists($plugin, 'setupWebhook')) {
|
||||
try {
|
||||
$reflection = new \ReflectionClass($plugin);
|
||||
$method = $reflection->getMethod('setupWebhook');
|
||||
$method->setAccessible(TRUE);
|
||||
$webhook_setup = $method->invoke($plugin, $gateway_id);
|
||||
|
||||
if ($webhook_setup) {
|
||||
$gateway->setPluginConfiguration($plugin->getConfiguration());
|
||||
$gateway->save();
|
||||
$webhook_message = 'Webhook configured successfully.';
|
||||
}
|
||||
else {
|
||||
$webhook_message = 'Could not configure webhook automatically.';
|
||||
}
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
$webhook_message = 'Error setting up webhook: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Show success page
|
||||
$settings_url = Url::fromRoute('entity.commerce_payment_gateway.collection')->toString();
|
||||
|
||||
return [
|
||||
'#markup' => $this->t('
|
||||
<div style="max-width: 800px; margin: 50px auto; padding: 20px; font-family: sans-serif;">
|
||||
<h1 style="color: #28a745;">✓ Payment Gateway Created Successfully!</h1>
|
||||
<p style="font-size: 16px; line-height: 1.6;">
|
||||
Your BTCPay Server payment gateway has been created and configured automatically.
|
||||
</p>
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||||
<p style="margin: 5px 0;"><strong>Gateway:</strong> BTCPay Server</p>
|
||||
<p style="margin: 5px 0;"><strong>Server URL:</strong> @server_url</p>
|
||||
<p style="margin: 5px 0;"><strong>Store ID:</strong> @store_id</p>
|
||||
<p style="margin: 5px 0;"><strong>API Key:</strong> @api_key_preview</p>
|
||||
<p style="margin: 5px 0;"><strong>Webhook:</strong> @webhook_status</p>
|
||||
</div>
|
||||
<p style="margin-top: 30px;">
|
||||
<a href="@settings_url" style="display: inline-block; padding: 12px 24px; background: #007bff; color: white; text-decoration: none; border-radius: 4px; font-weight: bold;">
|
||||
Go to Payment Gateways →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
', [
|
||||
'@server_url' => $server_url,
|
||||
'@store_id' => $store_id,
|
||||
'@api_key_preview' => substr($api_key, 0, 20) . '...',
|
||||
'@webhook_status' => $webhook_message,
|
||||
'@settings_url' => $settings_url,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Success page after API key authorization.
|
||||
*/
|
||||
public function apiKeySuccess() {
|
||||
$state = \Drupal::state();
|
||||
$pending_data = $state->get('commerce_btcpay.pending_config');
|
||||
|
||||
if (!$pending_data || empty($pending_data['timestamp'])) {
|
||||
return [
|
||||
'#markup' => $this->t('<h1>No Pending Authorization</h1><p>No pending authorization found. Please try the authorization process again.</p>'),
|
||||
];
|
||||
}
|
||||
|
||||
// Check if data is not older than 1 hour
|
||||
if ((time() - $pending_data['timestamp']) >= 3600) {
|
||||
$state->delete('commerce_btcpay.pending_config');
|
||||
return [
|
||||
'#markup' => $this->t('<h1>Authorization Expired</h1><p>The authorization has expired. Please try the authorization process again.</p>'),
|
||||
];
|
||||
}
|
||||
|
||||
$store_id = $pending_data['store_id'];
|
||||
$api_key_preview = substr($pending_data['api_key'], 0, 20) . '...';
|
||||
$settings_url = Url::fromRoute('entity.commerce_payment_gateway.collection')->toString();
|
||||
|
||||
return [
|
||||
'#markup' => $this->t('
|
||||
<div style="max-width: 800px; margin: 50px auto; padding: 20px; font-family: sans-serif;">
|
||||
<h1 style="color: #28a745;">✓ API Key Generated Successfully!</h1>
|
||||
<p style="font-size: 16px; line-height: 1.6;">
|
||||
Your BTCPay Server has been authorized successfully. The credentials are ready to use.
|
||||
</p>
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||||
<p style="margin: 5px 0;"><strong>Server URL:</strong> @server_url</p>
|
||||
<p style="margin: 5px 0;"><strong>Store ID:</strong> @store_id</p>
|
||||
<p style="margin: 5px 0;"><strong>API Key:</strong> @api_key_preview</p>
|
||||
</div>
|
||||
<h2>Next Steps:</h2>
|
||||
<ol style="font-size: 16px; line-height: 1.8;">
|
||||
<li>Go to <a href="@settings_url" style="color: #007bff;">Payment Gateway Settings</a></li>
|
||||
<li>Edit your BTCPay payment gateway (or create a new one)</li>
|
||||
<li>The Server URL, API Key, and Store ID will be automatically filled</li>
|
||||
<li>Click "Save" to complete the setup</li>
|
||||
</ol>
|
||||
<p style="margin-top: 30px;">
|
||||
<a href="@settings_url" style="display: inline-block; padding: 12px 24px; background: #007bff; color: white; text-decoration: none; border-radius: 4px; font-weight: bold;">
|
||||
Go to Payment Gateway Settings →
|
||||
</a>
|
||||
</p>
|
||||
<p style="margin-top: 20px; font-size: 14px; color: #666;">
|
||||
Note: These credentials will be available for 1 hour. Make sure to save your payment gateway configuration within this time.
|
||||
</p>
|
||||
</div>
|
||||
', [
|
||||
'@server_url' => $pending_data['server_url'],
|
||||
'@store_id' => $store_id,
|
||||
'@api_key_preview' => $api_key_preview,
|
||||
'@settings_url' => $settings_url,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the API key works with the server.
|
||||
*/
|
||||
protected function verifyApiKey($server_url, $api_key) {
|
||||
try {
|
||||
$client = new \BTCPayServer\Client\ApiKey($server_url, $api_key);
|
||||
// Try to get current user info to verify the key works
|
||||
$client->getCurrent();
|
||||
return TRUE;
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
\Drupal::logger('commerce_btcpay')->error('API key verification failed: @error', [
|
||||
'@error' => $e->getMessage(),
|
||||
]);
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the permissions are correct.
|
||||
*/
|
||||
protected function validatePermissions(array $permissions) {
|
||||
// Extract permission names (without store IDs)
|
||||
$permission_names = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$parts = explode(':', $permission);
|
||||
$permission_names[] = $parts[0];
|
||||
}
|
||||
|
||||
// Check if all required permissions are present
|
||||
$missing = array_diff(self::REQUIRED_PERMISSIONS, $permission_names);
|
||||
if (!empty($missing)) {
|
||||
return [
|
||||
'valid' => FALSE,
|
||||
'error' => $this->t('Missing required permissions: @permissions', [
|
||||
'@permissions' => implode(', ', $missing),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
// Check if all permissions are for the same store
|
||||
$store_ids = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$parts = explode(':', $permission);
|
||||
if (count($parts) === 2) {
|
||||
$store_ids[] = $parts[1];
|
||||
}
|
||||
}
|
||||
|
||||
$unique_stores = array_unique($store_ids);
|
||||
if (count($unique_stores) > 1) {
|
||||
return [
|
||||
'valid' => FALSE,
|
||||
'error' => $this->t('Permissions must be for a single store only.'),
|
||||
];
|
||||
}
|
||||
|
||||
return ['valid' => TRUE];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the store ID from permissions.
|
||||
*/
|
||||
protected function extractStoreId(array $permissions) {
|
||||
foreach ($permissions as $permission) {
|
||||
$parts = explode(':', $permission);
|
||||
if (count($parts) === 2) {
|
||||
return $parts[1];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects to payment gateway settings.
|
||||
*/
|
||||
protected function redirectToSettings() {
|
||||
return new RedirectResponse(Url::fromRoute('entity.commerce_payment_gateway.collection')->toString());
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,744 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\commerce_btcpay\Plugin\Commerce\PaymentGateway;
|
||||
|
||||
use Bitpay\Buyer;
|
||||
use Bitpay\Item;
|
||||
use Bitpay\Currency;
|
||||
use Bitpay\KeyManager;
|
||||
use Drupal\commerce_order\Entity\Order;
|
||||
use Bitpay\Bitpay;
|
||||
use Bitpay\Client\Adapter\CurlAdapter;
|
||||
use Bitpay\Invoice;
|
||||
use Bitpay\PrivateKey;
|
||||
use Bitpay\PublicKey;
|
||||
use Bitpay\SinKey;
|
||||
use Bitpay\Client\Client;
|
||||
use Bitpay\Network\Customnet;
|
||||
use Bitpay\Storage\EncryptedFilesystemStorage;
|
||||
use Bitpay\Token;
|
||||
use Drupal\commerce_checkout\CheckoutOrderManagerInterface;
|
||||
use Drupal\commerce_payment\PaymentMethodTypeManager;
|
||||
use Drupal\commerce_payment\PaymentTypeManager;
|
||||
use Drupal\commerce_price\Price;
|
||||
use Drupal\Component\Datetime\TimeInterface;
|
||||
use Drupal\Component\Utility\Crypt;
|
||||
use Drupal\commerce_order\Entity\OrderInterface;
|
||||
use Drupal\commerce_payment\Exception\PaymentGatewayException;
|
||||
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OffsitePaymentGatewayBase;
|
||||
use Drupal\Core\Entity\EntityTypeManagerInterface;
|
||||
use Drupal\Core\Form\FormStateInterface;
|
||||
use Drupal\Core\State\StateInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Provides the BTCPay off-site Redirect payment gateway.
|
||||
*
|
||||
* @CommercePaymentGateway(
|
||||
* id = "btcpay_redirect",
|
||||
* label = @Translation("BTCPay cryptocurrency (off-site redirect)"),
|
||||
* display_label = @Translation("Cryptocurrency (BTC, LTC, Lightning Network)"),
|
||||
* forms = {
|
||||
* "offsite-payment" = "Drupal\commerce_btcpay\PluginForm\BtcPayRedirectForm",
|
||||
* },
|
||||
* requires_billing_information = FALSE,
|
||||
* )
|
||||
*/
|
||||
class BtcPay extends OffsitePaymentGatewayBase {
|
||||
|
||||
/**
|
||||
* The logger.
|
||||
*
|
||||
* @var \Psr\Log\LoggerInterface
|
||||
*/
|
||||
protected $logger;
|
||||
|
||||
/**
|
||||
* The checkout order manager.
|
||||
*
|
||||
* @var \Drupal\commerce_checkout\CheckoutOrderManagerInterface
|
||||
*/
|
||||
protected $checkoutOrderManager;
|
||||
|
||||
/**
|
||||
* The state manager.
|
||||
*
|
||||
* @var \Drupal\Core\State\StateInterface
|
||||
*/
|
||||
protected $state;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, PaymentTypeManager $payment_type_manager, PaymentMethodTypeManager $payment_method_type_manager, TimeInterface $time, LoggerInterface $logger, CheckoutOrderManagerInterface $checkout_order_manager, StateInterface $state) {
|
||||
parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager, $payment_type_manager, $payment_method_type_manager, $time);
|
||||
|
||||
$this->logger = $logger;
|
||||
$this->checkoutOrderManager = $checkout_order_manager;
|
||||
$this->state = $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
|
||||
return new static(
|
||||
$configuration,
|
||||
$plugin_id,
|
||||
$plugin_definition,
|
||||
$container->get('entity_type.manager'),
|
||||
$container->get('plugin.manager.commerce_payment_type'),
|
||||
$container->get('plugin.manager.commerce_payment_method_type'),
|
||||
$container->get('datetime.time'),
|
||||
$container->get('commerce_btcpay.logger'),
|
||||
$container->get('commerce_checkout.checkout_order_manager'),
|
||||
$container->get('state')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function defaultConfiguration() {
|
||||
return [
|
||||
'mode' => 'test',
|
||||
'pairing_code_livenet' => '',
|
||||
'server_livenet' => '',
|
||||
'token_livenet' => '',
|
||||
'pairing_code_testnet' => '',
|
||||
'server_testnet' => '',
|
||||
'token_testnet' => '',
|
||||
'confirmation_speed' => 'medium',
|
||||
'debug_log' => NULL,
|
||||
'privacy_email' => NULL,
|
||||
'privacy_address' => '1',
|
||||
] + parent::defaultConfiguration();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
|
||||
// Show an error if no private filesystem is configured.
|
||||
if (!\Drupal::hasService('stream_wrapper.private')) {
|
||||
$this->messenger()->addError(t('Error: you have no private filesystem set up. Please do so before you continue! See docs on <a href="@link" target="_blank">how to configure private files</a> and rebuild cache afterwards.',
|
||||
['@link' => 'https://www.drupal.org/docs/8/core/modules/file/overview#content-accessing-private-files']
|
||||
));
|
||||
}
|
||||
|
||||
$form = parent::buildConfigurationForm($form, $form_state);
|
||||
|
||||
$form['server_livenet'] = [
|
||||
'#type' => 'textfield',
|
||||
'#title' => $this->t('Live server host'),
|
||||
'#description' => $this->t('Enter a custom live server (without leading https://) here, e.g. <strong>btcpay.domain.tld</strong>. Make sure the server is working with https:// and has a valid SSL certificate. You can define a custom port using a colon e.g. <strong>btcpay.domain.tld:8080</strong>.'),
|
||||
'#default_value' => $this->configuration['server_livenet'],
|
||||
'#states' => [
|
||||
'visible' => [
|
||||
':input[name="configuration[btcpay_redirect][mode]"]' => ['value' => 'live'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$form['pairing_code_livenet'] = [
|
||||
'#type' => 'textfield',
|
||||
'#title' => $this->t('Live server pairing code'),
|
||||
'#description' => $this->t('Visit your Manage API Tokens page (on your <strong>btcpay.domain.tld</strong>), click the "Add New Token" button, leave the "Require Authentication" checkbox checked, and enter the pairing code here.'),
|
||||
'#default_value' => $this->configuration['pairing_code_livenet'],
|
||||
'#states' => [
|
||||
'visible' => [
|
||||
':input[name="configuration[btcpay_redirect][mode]"]' => ['value' => 'live'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$form['token_livenet'] = [
|
||||
'#type' => 'item',
|
||||
'#title' => $this->t('Live API token status'),
|
||||
'#description' => $this->configuration['token_livenet'] ? $this->t('Configured') : $this->t('Not configured'),
|
||||
'#states' => [
|
||||
'visible' => [
|
||||
':input[name="configuration[btcpay_redirect][mode]"]' => ['value' => 'live'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$form['server_testnet'] = [
|
||||
'#type' => 'textfield',
|
||||
'#title' => $this->t('Test server host'),
|
||||
'#description' => $this->t('Enter a custom test server (without leading https://) here, e.g. <strong>btcpay.domain.tld</strong>. Make sure the server is working with https:// and has a valid SSL certificate. You can define a custom port using a colon e.g. <strong>btcpay.domain.tld:8080</strong>.'),
|
||||
'#default_value' => $this->configuration['server_testnet'],
|
||||
'#states' => [
|
||||
'visible' => [
|
||||
':input[name="configuration[btcpay_redirect][mode]"]' => ['value' => 'test'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$form['pairing_code_testnet'] = [
|
||||
'#type' => 'textfield',
|
||||
'#title' => $this->t('Test pairing code'),
|
||||
'#description' => $this->t('Visit your Manage API Tokens page (on your <strong>btcpay.domain.tld</strong>), click the "Add New Token" button, leave the "Require Authentication" checkbox checked, and enter the pairing code here.'),
|
||||
'#default_value' => $this->configuration['pairing_code_testnet'],
|
||||
'#states' => [
|
||||
'visible' => [
|
||||
':input[name="configuration[btcpay_redirect][mode]"]' => ['value' => 'test'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$form['token_testnet'] = [
|
||||
'#type' => 'item',
|
||||
'#title' => $this->t('Test API token status'),
|
||||
'#description' => $this->configuration['token_testnet'] ? $this->t('Configured') : $this->t('Not configured'),
|
||||
'#states' => [
|
||||
'visible' => [
|
||||
':input[name="configuration[btcpay_redirect][mode]"]' => ['value' => 'test'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$form['confirmation_speed'] = [
|
||||
'#type' => 'select',
|
||||
'#title' => $this->t('Confirmation speed'),
|
||||
'#description' => $this->t('Choose after how many confirmations you accept a payment as fully paid ("high": 0-confirmations (only for small sums, danger of double spends), "medium" (default): at least 1 confirmation (~10 minutes), "low" at least 6 confirmations (~1 hour). Note: Lightning Network payments are always assumed to have >6 confirmations as they settle immediately.'),
|
||||
'#default_value' => $this->configuration['confirmation_speed'],
|
||||
'#options' => [
|
||||
'high' => $this->t('High'),
|
||||
'medium' => $this->t('Medium'),
|
||||
'low' => $this->t('Low'),
|
||||
],
|
||||
];
|
||||
|
||||
$form['privacy'] = [
|
||||
'#type' => 'fieldset',
|
||||
'#title' => t('Privacy settings'),
|
||||
'#collapsible' => TRUE,
|
||||
'#collapsed' => FALSE,
|
||||
];
|
||||
|
||||
$form['privacy']['privacy_address'] = [
|
||||
'#type' => 'checkbox',
|
||||
'#title' => $this->t('Do NOT transfer customer billing address'),
|
||||
'#description' => $this->t('Check this if you do NOT want to transfer customer billing data to BTCPay Server.'),
|
||||
'#return_value' => '1',
|
||||
'#default_value' => $this->configuration['privacy_address'],
|
||||
];
|
||||
|
||||
$form['privacy']['privacy_email'] = [
|
||||
'#type' => 'checkbox',
|
||||
'#title' => $this->t('Do NOT transfer customer e-mail'),
|
||||
'#description' => $this->t('Check this if you do NOT want to transfer customer e-mail to BTCPay Server. Customer will be asked for e-mail on BTCPay payment page.'),
|
||||
'#return_value' => '1',
|
||||
'#default_value' => $this->configuration['privacy_email'],
|
||||
];
|
||||
|
||||
$form['debug_log'] = [
|
||||
'#type' => 'checkbox',
|
||||
'#title' => $this->t('Enable verbose logging for debugging.'),
|
||||
'#return_value' => '1',
|
||||
'#default_value' => $this->configuration['debug_log'],
|
||||
];
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
|
||||
parent::validateConfigurationForm($form, $form_state);
|
||||
|
||||
if (!$form_state->getErrors() && $form_state->isSubmitted()) {
|
||||
// TODO: check values hostname, pairing code etc.
|
||||
// TODO: check if private filesystem is configured in drupal.
|
||||
$values = $form_state->getValue($form['#parents']);
|
||||
$this->configuration['server_livenet'] = $values['server_livenet'];
|
||||
$this->configuration['pairing_code_livenet'] = $values['pairing_code_livenet'];
|
||||
$this->configuration['server_testnet'] = $values['server_testnet'];
|
||||
$this->configuration['pairing_code_testnet'] = $values['pairing_code_testnet'];
|
||||
$this->configuration['confirmation_speed'] = $values['confirmation_speed'];
|
||||
$this->configuration['debug_log'] = $values['debug_log'];
|
||||
$this->configuration['privacy_email'] = $values['privacy']['privacy_email'];
|
||||
$this->configuration['privacy_address'] = $values['privacy']['privacy_address'];
|
||||
$this->configuration['mode'] = $values['mode'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
|
||||
parent::submitConfigurationForm($form, $form_state);
|
||||
|
||||
if (!$form_state->getErrors()) {
|
||||
$values = $form_state->getValue($form['#parents']);
|
||||
$this->configuration['server_livenet'] = $values['server_livenet'];
|
||||
$this->configuration['pairing_code_livenet'] = '';
|
||||
$this->configuration['server_testnet'] = $values['server_testnet'];
|
||||
$this->configuration['pairing_code_testnet'] = '';
|
||||
$this->configuration['confirmation_speed'] = $values['confirmation_speed'];
|
||||
$this->configuration['debug_log'] = $values['debug_log'];
|
||||
$this->configuration['privacy_email'] = $values['privacy']['privacy_email'];
|
||||
$this->configuration['privacy_address'] = $values['privacy']['privacy_address'];
|
||||
|
||||
// Create new keys and tokens on BTCPay Server if we have a pairing code.
|
||||
$networks = ['livenet' => 'pairing_code_livenet', 'testnet' => 'pairing_code_testnet'];
|
||||
foreach ($networks as $network => $setting) {
|
||||
if (!empty($values["$setting"])) {
|
||||
$this->createToken($network, $values["$setting"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function onReturn(OrderInterface $order, Request $request) {
|
||||
// Get BTCPay payment data from order.
|
||||
$order_btcpay_data = $order->getData('btcpay');
|
||||
if (empty($order_btcpay_data['invoice_id'])) {
|
||||
throw new PaymentGatewayException('Invoice id missing for this BTCPay transaction.');
|
||||
}
|
||||
|
||||
// As original BitPay API has no tokens to verify the counterparty server,
|
||||
// we need to query the invoice state to ensure it is payed.
|
||||
if (!$invoice = $this->getInvoice($order_btcpay_data['invoice_id'])) {
|
||||
// TODO: silently fail, display message and redirect back to cart.
|
||||
throw new PaymentGatewayException('Invoice not found.');
|
||||
}
|
||||
|
||||
// If the user is anonymous and they provided the email during payment, add
|
||||
// it to the order.
|
||||
if (empty($order->mail)) {
|
||||
$order->setEmail($request->query->get('buyerEmail'));
|
||||
}
|
||||
$order->save();
|
||||
|
||||
$this->processPayment($invoice);
|
||||
|
||||
if ($this->checkInvoicePaymentFailed($invoice) === TRUE) {
|
||||
// If the payment failed (voided/expired) for some reason we need to
|
||||
// handle that one here. As BitPay/BTCPay API does not support a cancel
|
||||
// url.
|
||||
$this->redirectOnPaymentError($order);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function onNotify(Request $request) {
|
||||
if ($this->debugEnabled()) {
|
||||
$this->logger->debug(print_r($request->getContent(), TRUE));
|
||||
}
|
||||
|
||||
if (!$responseData = json_decode($request->getContent(), TRUE)) {
|
||||
throw new PaymentGatewayException('Response data missing, aborting.');
|
||||
}
|
||||
|
||||
if (empty($responseData['id'])) {
|
||||
throw new PaymentGatewayException('Invoice id missing for this BTCPay transaction, aborting.');
|
||||
}
|
||||
|
||||
// As original BitPay API has no tokens to verify the counterparty server,
|
||||
// we need to query the invoice state to ensure it is payed.
|
||||
/** @var \Bitpay\Invoice $invoice */
|
||||
$invoice = $this->getInvoice($responseData['id']);
|
||||
if (empty($invoice)) {
|
||||
throw new PaymentGatewayException('Invoice not found on BTCPay server.');
|
||||
}
|
||||
|
||||
/** @var \Drupal\commerce_order\Entity\OrderInterface $order */
|
||||
if (!$order = Order::load($invoice->getOrderId())) {
|
||||
throw new PaymentGatewayException('Could not find matching order.');
|
||||
}
|
||||
|
||||
// Set the order to next state after draft (so that the order is placed) if
|
||||
// there is an payment completed.
|
||||
if ($payment = $this->processPayment($invoice) && $this->checkInvoicePaidFull($invoice)) {
|
||||
/** @var \Drupal\state_machine\Plugin\Field\FieldType\StateItemInterface $state_item */
|
||||
$state_item = $order->get('state')->first();
|
||||
$current_state = $state_item->getValue();
|
||||
|
||||
// We only want to transition order from "draft" to next state.
|
||||
if ($current_state['value'] !== 'draft') {
|
||||
$this->logger->info(t('onNotify callback: skipping order state transition, order already not in "draft" state anymore, current state: @current-state', ['@current-state' => $current_state['value']]));
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
// Load transitions and apply the next one (place the order).
|
||||
if ($transitions = $state_item->getTransitions()) {
|
||||
$state_item->applyTransition(current($transitions));
|
||||
// Unlock the order if needed.
|
||||
$order->isLocked() ? $order->unlock() : NULL;
|
||||
$order->save();
|
||||
$this->logger->info(t('onNotify callback: set transition successfully.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function processPayment(Invoice $invoice) {
|
||||
// Load the order.
|
||||
/** @var \Drupal\commerce_order\Entity\OrderInterface $order */
|
||||
if (!$order = Order::load($invoice->getOrderId())) {
|
||||
throw new PaymentGatewayException('processPayment: Could not find matching order.');
|
||||
}
|
||||
|
||||
$paymentState = $this->mapRemotePaymentState($invoice->getStatus());
|
||||
|
||||
/** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */
|
||||
// Check if the IPN callback (onNotify) already created a payment entry.
|
||||
if (!empty($payment = $this->loadExistingPayment($order, $invoice))) {
|
||||
$payment->setState($paymentState);
|
||||
$payment->setRemoteState($invoice->getStatus());
|
||||
$payment->setAmount($this->calculateAmountPaid($invoice));
|
||||
$payment->save();
|
||||
|
||||
}
|
||||
else {
|
||||
// As no payment for that order ID exists create a new one.
|
||||
$payment_storage = $this->entityTypeManager->getStorage('commerce_payment');
|
||||
$payment = $payment_storage->create([
|
||||
'state' => $paymentState,
|
||||
'amount' => $this->calculateAmountPaid($invoice),
|
||||
'payment_gateway' => $this->entityId,
|
||||
'order_id' => $order->id(),
|
||||
'remote_id' => $invoice->getId(),
|
||||
'remote_state' => $invoice->getStatus(),
|
||||
]);
|
||||
$payment->save();
|
||||
}
|
||||
|
||||
return (!empty($payment)) ? $payment : NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function mapRemotePaymentState($remoteState) {
|
||||
// TODO: currently does not handle refunded payments.
|
||||
// TODO: custom payment workflows suited for BTCPay.
|
||||
$mappedState = '';
|
||||
switch ($remoteState) {
|
||||
case "paid":
|
||||
$mappedState = "authorization";
|
||||
break;
|
||||
|
||||
case "confirmed":
|
||||
case "complete":
|
||||
$mappedState = "completed";
|
||||
break;
|
||||
|
||||
case "expired":
|
||||
$mappedState = "authorization_expired";
|
||||
break;
|
||||
|
||||
case "invalid":
|
||||
$mappedState = "authorization_voided";
|
||||
break;
|
||||
}
|
||||
|
||||
return $mappedState;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getServerConfig() {
|
||||
if ($this->getMode() === 'live') {
|
||||
return $this->prepareServerUrl($this->configuration['server_livenet']);
|
||||
}
|
||||
else {
|
||||
return $this->prepareServerUrl($this->configuration['server_testnet']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the server URL.
|
||||
*/
|
||||
private function prepareServerUrl($url) {
|
||||
$host = explode(':', $url);
|
||||
if (!isset($host[1])) {
|
||||
$host[1] = '443';
|
||||
}
|
||||
|
||||
return $host;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function createInvoice(OrderInterface $order = NULL, $options = []) {
|
||||
$invoice = new Invoice();
|
||||
$currency = new Currency();
|
||||
$currency->setCode($order->getTotalPrice()->getCurrencyCode());
|
||||
$invoice->setCurrency($currency);
|
||||
$invoice->setPrice($order->getTotalPrice()->getNumber());
|
||||
$invoice->setPaymentTotals($order->getTotalPrice()->getNumber());
|
||||
$invoice->setOrderId($order->id());
|
||||
$invoice->setPosData($order->id());
|
||||
$invoice->setTransactionSpeed($this->configuration['confirmation_speed']);
|
||||
|
||||
// As bitpay API currently supports only one item we set it to the store
|
||||
// name.
|
||||
$item = new Item();
|
||||
$entity_manager = \Drupal::entityTypeManager();
|
||||
$store = $entity_manager->getStorage('commerce_store')->load($order->getStoreId());
|
||||
$item->setDescription($store->getName());
|
||||
$item->setPrice($order->getTotalPrice()->getNumber());
|
||||
$invoice->setItem($item);
|
||||
|
||||
// Only add customer data if allowed by privacy settings.
|
||||
if ($this->configuration['privacy_email'] !== '1' || $this->configuration['privacy_address'] !== '1') {
|
||||
// Prepare BTCPay buyer object.
|
||||
$buyer = new Buyer();
|
||||
|
||||
// Only set customer data if billing profile is enaled and also honor
|
||||
// address privacy setting.
|
||||
if ($order->getBillingProfile() && $this->configuration['privacy_address'] !== '1') {
|
||||
/** @var \Drupal\address\Plugin\Field\FieldType\AddressItem $billing_address */
|
||||
$billing_address = $order->getBillingProfile()->get('address')->first();
|
||||
$buyer->setFirstName($billing_address->getGivenName())
|
||||
->setLastName($billing_address->getFamilyName())
|
||||
->setAddress([
|
||||
$billing_address->getAddressLine1(),
|
||||
$billing_address->getAddressLine2(),
|
||||
])
|
||||
->setCity($billing_address->getLocality())
|
||||
->setState($billing_address->getAdministrativeArea())
|
||||
->setZip($billing_address->getPostalCode())
|
||||
->setCountry($billing_address->getCountryCode());
|
||||
}
|
||||
|
||||
// Only set customer email if not disabled.
|
||||
if ($this->configuration['privacy_email'] !== '1') {
|
||||
$buyer->setEmail($order->getEmail());
|
||||
}
|
||||
|
||||
$invoice->setBuyer($buyer);
|
||||
}
|
||||
|
||||
// Set return url (where external payment provider should redirect to).
|
||||
$invoice->setRedirectUrl($options['return_url']);
|
||||
// Set notification url.
|
||||
$invoice->setNotificationUrl($this->getNotifyUrl()->toString());
|
||||
|
||||
try {
|
||||
$client = $this->getBtcPayClient();
|
||||
return $client->createInvoice($invoice);
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
$this->logger->error(t('Error on creating invoice on remote server: @error', ['@error' => $e->getMessage()]));
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getInvoice($invoiceId) {
|
||||
try {
|
||||
$client = $this->getBtcPayClient();
|
||||
$invoice = $client->getInvoice($invoiceId);
|
||||
|
||||
if (empty($invoice->getId())) {
|
||||
$this->logger->error(t('Error getting invoice data from remote server, likely authorization problem, or non existing invoice id.'));
|
||||
return NULL;
|
||||
}
|
||||
return $invoice;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error(t('Error getting invoice from remote server: @error', ['@error' => $e->getMessage()]));
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function checkInvoicePaidFull($invoice) {
|
||||
$confirmedStates = ['paid', 'confirmed', 'complete'];
|
||||
|
||||
return in_array($invoice->getStatus(), $confirmedStates);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function checkInvoicePaymentFailed($invoice) {
|
||||
$errorStates = ['expired', 'invalid'];
|
||||
|
||||
return in_array($invoice->getStatus(), $errorStates);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function loadExistingPayment($order, $invoice) {
|
||||
$payment_storage = $this->entityTypeManager->getStorage('commerce_payment');
|
||||
$payments = $payment_storage->loadByProperties(['order_id' => $order->id(), 'remote_id' => $invoice->getId()]);
|
||||
return array_pop($payments);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function redirectOnPaymentError($order) {
|
||||
$this->messenger()->addError(t('The payment process could be completed due to expired or canceled invoice. Please try again or change payment option in previous step by clicking on the [back] button.'));
|
||||
|
||||
/** @var \Drupal\commerce_checkout\Entity\CheckoutFlowInterface $checkout_flow */
|
||||
$checkout_flow = $order->get('checkout_flow')->entity;
|
||||
$checkout_flow_plugin = $checkout_flow->getPlugin();
|
||||
$step_id = $this->checkoutOrderManager->getCheckoutStepId($order);
|
||||
$previous_step_id = $checkout_flow_plugin->getPreviousStepId($step_id);
|
||||
$checkout_flow_plugin->redirectToStep($previous_step_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function createToken($network, $pairing_code) {
|
||||
// TODO: refactor, not sure what the point is to instantiate Bitpay class,
|
||||
// seems only used for config variables set/get/only keymanager but not
|
||||
// private keys. Or other way around use it also for api invoice requests.
|
||||
global $base_url;
|
||||
$password = Crypt::randomBytesBase64();
|
||||
$bitpay = new Bitpay([
|
||||
'bitpay' => [
|
||||
'key_storage_password' => $password,
|
||||
'network' => $network,
|
||||
// We can only use the private files root path here as BitPay library
|
||||
// uses `file_put_contents()` which can't create subfolders.
|
||||
'private_key' => "private://btcpay_$network.key",
|
||||
'public_key' => "private://btcpay_$network.pub",
|
||||
],
|
||||
]);
|
||||
try {
|
||||
// Generate and store private key.
|
||||
$storage = new EncryptedFilesystemStorage($password);
|
||||
$keyManager = new KeyManager($storage);
|
||||
$privateKey = new PrivateKey($bitpay->getContainer()->getParameter('bitpay.private_key'));
|
||||
$privateKey->generate();
|
||||
$keyManager->persist($privateKey);
|
||||
// Generate and store public key.
|
||||
$publicKey = new PublicKey($bitpay->getContainer()->getParameter('bitpay.public_key'));
|
||||
$publicKey->setPrivateKey($privateKey);
|
||||
$publicKey->generate();
|
||||
$keyManager->persist($publicKey);
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
$this->messenger()->addError($this->t('Failed to create key pair: %message', ['%message' => $e->getMessage()]));
|
||||
return;
|
||||
}
|
||||
// Create API access token.
|
||||
$sin = new SinKey();
|
||||
$sin->setPublicKey($publicKey);
|
||||
$sin->generate();
|
||||
$client = new Client();
|
||||
// Use our custom network (btcpay) server.
|
||||
$host = $this->getServerConfig();
|
||||
$remoteNetwork = new Customnet($host[0], $host[1]);
|
||||
$client->setNetwork($remoteNetwork);
|
||||
try {
|
||||
$token = $client->createToken([
|
||||
'id' => (string) $sin,
|
||||
'pairingCode' => $pairing_code,
|
||||
'label' => $base_url,
|
||||
]);
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
$this->messenger()->addError($this->t('Failed to create @network token: %message', [
|
||||
'%message' => $e->getMessage(),
|
||||
'@network' => $network,
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the non user visible data using drupal state api, as non visible
|
||||
// config gets.
|
||||
// wiped in parent::submitConfigurationForm.
|
||||
$this->state->set("commerce_btcpay.token_$network", (string) $token);
|
||||
$this->state->set("commerce_btcpay.private_key_password_$network", $password);
|
||||
$this->messenger()->addStatus($this->t('New @network API token generated successfully. Encrypted keypair saved to the private filesystem.', ['@network' => $network]));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getBtcPayClient() {
|
||||
// TODO: refactor to use Bitpay class? with common config abstraction
|
||||
// (getBtcPayService?())
|
||||
$network = $this->getMode() . 'net';
|
||||
|
||||
try {
|
||||
$client = new Client();
|
||||
|
||||
$host = $this->getServerConfig();
|
||||
$remoteNetwork = new Customnet($host[0], $host[1]);
|
||||
$client->setNetwork($remoteNetwork);
|
||||
|
||||
$adapter = new CurlAdapter();
|
||||
$client->setAdapter($adapter);
|
||||
|
||||
$token = new Token();
|
||||
$token->setToken($this->state->get("commerce_btcpay.token_$network"));
|
||||
// Todo: further investigate: without setting this the php client library
|
||||
// does not call the invoice endpoint with correct token parameter on
|
||||
// calling getInvoice().
|
||||
$token->setFacade('merchant');
|
||||
$client->setToken($token);
|
||||
|
||||
$storageEngine = new EncryptedFilesystemStorage($this->state->get("commerce_btcpay.private_key_password_$network"));
|
||||
$privateKey = $storageEngine->load("private://btcpay_$network.key");
|
||||
$publicKey = $storageEngine->load("private://btcpay_$network.pub");
|
||||
$client->setPrivateKey($privateKey);
|
||||
$client->setPublicKey($publicKey);
|
||||
|
||||
return $client;
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
$this->logger->error(t('Error getting BitPay Client: @error', ['@error' => $e->getMessage()]));
|
||||
return NULL;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if verbose logging enabled.
|
||||
*
|
||||
* @return bool
|
||||
* Whether debugging is enabled or not.
|
||||
*/
|
||||
protected function debugEnabled() {
|
||||
return $this->configuration['debug_log'] == 1 ? TRUE : FALSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the total fiat amount paid.
|
||||
*
|
||||
* @param \Bitpay\Invoice $invoice
|
||||
* The Bitpay invoice to calculate the paid amount for.
|
||||
*
|
||||
* @return \Drupal\commerce_price\Price
|
||||
* The price object for the amount paid.
|
||||
*/
|
||||
protected function calculateAmountPaid(Invoice $invoice) {
|
||||
// Todo: for now we only update the amount when the payment is complete,
|
||||
// extend that to partial payments across multiple cryptocurrencies
|
||||
// https://github.com/btcpayserver/commerce_btcpay/issues/7
|
||||
$allowed_states = ['confirmed', 'complete'];
|
||||
if (in_array($invoice->getStatus(), $allowed_states)) {
|
||||
return new Price((string) $invoice->getPrice(), $invoice->getCurrency()->getCode());
|
||||
}
|
||||
else {
|
||||
return new Price('0.00', $invoice->getCurrency()->getCode());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -2,139 +2,44 @@
|
||||
|
||||
namespace Drupal\commerce_btcpay\Plugin\Commerce\PaymentGateway;
|
||||
|
||||
use Bitpay\InvoiceInterface;
|
||||
use Drupal\commerce_order\Entity\OrderInterface;
|
||||
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsNotificationsInterface;
|
||||
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OffsitePaymentGatewayInterface;
|
||||
|
||||
/**
|
||||
* Provides the interface for the BTCPay payment gateway.
|
||||
*/
|
||||
interface BtcPayInterface extends SupportsNotificationsInterface {
|
||||
interface BtcPayInterface extends OffsitePaymentGatewayInterface {
|
||||
|
||||
/**
|
||||
* Gets the server URL.
|
||||
* Gets the BTCPay Greenfield API invoice client.
|
||||
*
|
||||
* @return array
|
||||
* Returns an array with host and port info: 1 => 'host', 2 => 'port'.
|
||||
* Defaults to port 443.
|
||||
* @return \BTCPayServer\Client\Invoice|null
|
||||
* Returns the invoice client or NULL.
|
||||
*/
|
||||
public function getServerConfig();
|
||||
|
||||
/**
|
||||
* Creates and saves a key pair and token.
|
||||
*
|
||||
* @param string $network
|
||||
* Network string we want to configure.
|
||||
* @param string $pairing_code
|
||||
* Pairing code provided by BTCPayServer.
|
||||
*
|
||||
* @copyright Code heavily inspired from BitPay module: https://dgo.to/bitpay
|
||||
* @author Mark Burdett (mfb)
|
||||
*/
|
||||
public function createToken($network, $pairing_code);
|
||||
|
||||
/**
|
||||
* Instantiate and return REST API Client.
|
||||
*
|
||||
* @return \Bitpay\Client\Client|null
|
||||
* Returns the client or NULL.
|
||||
*/
|
||||
public function getBtcPayClient();
|
||||
public function getInvoiceClient();
|
||||
|
||||
/**
|
||||
* Creates an invoice on BTCPay server.
|
||||
*
|
||||
* @param \Drupal\commerce_order\Entity\OrderInterface $order
|
||||
* The order entity, or null.
|
||||
* The order entity.
|
||||
* @param array $options
|
||||
* Optional data like redirect url etc.
|
||||
*
|
||||
* @return \Bitpay\Invoice
|
||||
* the created invoice.
|
||||
* @return \BTCPayServer\Result\Invoice|null
|
||||
* The created invoice or NULL on failure.
|
||||
*/
|
||||
public function createInvoice(OrderInterface $order = NULL, array $options = []);
|
||||
public function createInvoice(OrderInterface $order, array $options = []);
|
||||
|
||||
/**
|
||||
* Get BTCPay details to an existing invoice.
|
||||
*
|
||||
* Builds the data for the request and make the request.
|
||||
* Get BTCPay invoice details.
|
||||
*
|
||||
* @param string $invoiceId
|
||||
* The remote invoice ID.
|
||||
*
|
||||
* @return \Bitpay\Invoice|null
|
||||
* @return \BTCPayServer\Result\Invoice|null
|
||||
* The queried invoice or NULL.
|
||||
*/
|
||||
public function getInvoice($invoiceId);
|
||||
|
||||
/**
|
||||
* Check BTCPay server invoice and check status.
|
||||
*
|
||||
* @param \Bitpay\InvoiceInterface $invoice
|
||||
* Remote BTCPay invoice.
|
||||
*
|
||||
* @return bool
|
||||
* Whether or not the invoice was paid in full.
|
||||
*/
|
||||
public function checkInvoicePaidFull(InvoiceInterface $invoice);
|
||||
|
||||
/**
|
||||
* Check BTCPay server invoice on payment error states.
|
||||
*
|
||||
* @param \Bitpay\InvoiceInterface $invoice
|
||||
* Remote BTCPay invoice.
|
||||
*
|
||||
* @return bool
|
||||
* Whether or not the invoice payment failed.
|
||||
*/
|
||||
public function checkInvoicePaymentFailed(InvoiceInterface $invoice);
|
||||
|
||||
/**
|
||||
* Check if a payment entity for a given order and invoice combination exists.
|
||||
*
|
||||
* @param \Drupal\commerce_order\Entity\OrderInterface $order
|
||||
* The order entity, or null.
|
||||
* @param \Bitpay\InvoiceInterface $invoice
|
||||
* Remote BTCPay invoice.
|
||||
*
|
||||
* @return \Drupal\commerce_payment\Entity\PaymentInterface|null
|
||||
* The Payment if exists or null otherwise.
|
||||
*/
|
||||
public function loadExistingPayment(OrderInterface $order, InvoiceInterface $invoice);
|
||||
|
||||
/**
|
||||
* Handle payment error and redirect previous checkout step.
|
||||
*
|
||||
* @param \Drupal\commerce_order\Entity\OrderInterface $order
|
||||
* The order entity, or null.
|
||||
* @param bool $nonInteractive
|
||||
* Workaround param to handle redirects onNotify() which is non user facing.
|
||||
*/
|
||||
public function redirectOnPaymentError(OrderInterface $order, $nonInteractive = FALSE);
|
||||
|
||||
/**
|
||||
* Update/create payment for order.
|
||||
*
|
||||
* Handles creation or update of existing payment entities.
|
||||
*
|
||||
* @param \Bitpay\InvoiceInterface $invoice
|
||||
* Remote BTCPay invoice.
|
||||
*
|
||||
* @return \Drupal\commerce_payment\Entity\PaymentInterface|null
|
||||
* The Payment entity if successful or null otherwise.
|
||||
*/
|
||||
public function processPayment(InvoiceInterface $invoice);
|
||||
|
||||
/**
|
||||
* Map the remote payment state to some available Commerce payment state.
|
||||
*
|
||||
* TODO: think about how to handle overpayments (duplicate/multiple)
|
||||
* payments? Create new payment or update old?
|
||||
* TODO: maybe add remote payment states to Commerce payment states?
|
||||
*
|
||||
* @param string $remoteState
|
||||
* Remote BTCPay invoice state.
|
||||
*/
|
||||
public function mapRemotePaymentState($remoteState);
|
||||
public function getInvoice(string $invoiceId);
|
||||
|
||||
}
|
||||
|
||||
942
src/Plugin/Commerce/PaymentGateway/BtcPayRedirect.php
Normal file
942
src/Plugin/Commerce/PaymentGateway/BtcPayRedirect.php
Normal file
@ -0,0 +1,942 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\commerce_btcpay\Plugin\Commerce\PaymentGateway;
|
||||
|
||||
use BTCPayServer\Client\Invoice;
|
||||
use BTCPayServer\Client\InvoiceCheckoutOptions;
|
||||
use BTCPayServer\Client\Webhook;
|
||||
use BTCPayServer\Result\Invoice as InvoiceResult;
|
||||
use BTCPayServer\Util\PreciseNumber;
|
||||
use Drupal\commerce_checkout\CheckoutOrderManagerInterface;
|
||||
use Drupal\commerce_order\Entity\OrderInterface;
|
||||
use Drupal\commerce_payment\Entity\PaymentInterface;
|
||||
use Drupal\commerce_payment\Exception\PaymentGatewayException;
|
||||
use Drupal\commerce_payment\PaymentMethodTypeManager;
|
||||
use Drupal\commerce_payment\PaymentTypeManager;
|
||||
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OffsitePaymentGatewayBase;
|
||||
use Drupal\commerce_price\Price;
|
||||
use Drupal\Component\Datetime\TimeInterface;
|
||||
use Drupal\Core\Entity\EntityTypeManagerInterface;
|
||||
use Drupal\Core\Form\FormStateInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Drupal\Core\StringTranslation\StringTranslationTrait;
|
||||
|
||||
/**
|
||||
* Provides the BTCPay off-site Redirect payment gateway.
|
||||
*
|
||||
* @CommercePaymentGateway(
|
||||
* id = "btcpay_redirect",
|
||||
* label = @Translation("BTCPay Server (Off-site redirect)"),
|
||||
* display_label = @Translation("Pay with Bitcoin, Lightning Network"),
|
||||
* forms = {
|
||||
* "offsite-payment" = "Drupal\commerce_btcpay\PluginForm\BtcPayRedirectForm",
|
||||
* },
|
||||
* payment_type = "payment_default",
|
||||
* requires_billing_information = FALSE,
|
||||
* )
|
||||
*/
|
||||
class BtcPayRedirect extends OffsitePaymentGatewayBase implements BtcPayInterface {
|
||||
use StringTranslationTrait;
|
||||
|
||||
/**
|
||||
* The logger.
|
||||
*
|
||||
* @var \Psr\Log\LoggerInterface
|
||||
*/
|
||||
protected $logger;
|
||||
|
||||
/**
|
||||
* The checkout order manager.
|
||||
*
|
||||
* @var \Drupal\commerce_checkout\CheckoutOrderManagerInterface
|
||||
*/
|
||||
protected $checkoutOrderManager;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, PaymentTypeManager $payment_type_manager, PaymentMethodTypeManager $payment_method_type_manager, TimeInterface $time, CheckoutOrderManagerInterface $checkout_order_manager, LoggerInterface $logger) {
|
||||
parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager, $payment_type_manager, $payment_method_type_manager, $time);
|
||||
$this->checkoutOrderManager = $checkout_order_manager;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
|
||||
return new static(
|
||||
$configuration,
|
||||
$plugin_id,
|
||||
$plugin_definition,
|
||||
$container->get('entity_type.manager'),
|
||||
$container->get('plugin.manager.commerce_payment_type'),
|
||||
$container->get('plugin.manager.commerce_payment_method_type'),
|
||||
$container->get('datetime.time'),
|
||||
$container->get('commerce_checkout.checkout_order_manager'),
|
||||
$container->get('logger.factory')->get('commerce_btcpay')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getMode() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function defaultConfiguration() {
|
||||
return [
|
||||
'server_url' => '',
|
||||
'api_key' => '',
|
||||
'store_id' => '',
|
||||
'webhook_secret' => '',
|
||||
'webhook_id' => '',
|
||||
'debug_mode' => FALSE,
|
||||
// Offsite gateways don't collect billing information or payment methods.
|
||||
'collect_billing_information' => FALSE,
|
||||
'payment_method_types' => [],
|
||||
] + parent::defaultConfiguration();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
|
||||
// Ensure configuration has default values before calling parent.
|
||||
$this->configuration += $this->defaultConfiguration();
|
||||
|
||||
$form = parent::buildConfigurationForm($form, $form_state);
|
||||
|
||||
// Attach the API key redirect JavaScript library.
|
||||
$form['#attached']['library'][] = 'commerce_btcpay/api_key_redirect';
|
||||
|
||||
// Get the payment gateway entity ID from the form state
|
||||
$gateway = $form_state->getFormObject()->getEntity();
|
||||
$gateway_id = $gateway->id();
|
||||
|
||||
// Pass the gateway entity ID to JavaScript
|
||||
$form['#attached']['drupalSettings']['commerce_btcpay']['gateway_id'] = $gateway_id;
|
||||
|
||||
// Hide fields not applicable to offsite payment gateways.
|
||||
$form['mode']['#access'] = FALSE;
|
||||
// For offsite gateways, we don't collect billing information or payment methods.
|
||||
if (isset($form['collect_billing_information'])) {
|
||||
$form['collect_billing_information']['#access'] = FALSE;
|
||||
$form['collect_billing_information']['#value'] = FALSE;
|
||||
}
|
||||
if (isset($form['payment_method_types'])) {
|
||||
$form['payment_method_types']['#access'] = FALSE;
|
||||
$form['payment_method_types']['#value'] = [];
|
||||
}
|
||||
|
||||
$form['server_url'] = [
|
||||
'#type' => 'url',
|
||||
'#title' => $this->t('BTCPay Server URL'),
|
||||
'#description' => $this->t('Enter your BTCPay Server URL (e.g., https://btcpay.example.com). Note: .local domains only work on your local network.'),
|
||||
'#default_value' => $this->configuration['server_url'] ?? '',
|
||||
'#required' => TRUE,
|
||||
];
|
||||
|
||||
$form['generate_api_key'] = [
|
||||
'#type' => 'button',
|
||||
'#value' => $this->t('Generate API Key'),
|
||||
'#attributes' => [
|
||||
'class' => ['btcpay-generate-api-key', 'button', 'button--primary'],
|
||||
],
|
||||
'#prefix' => '<div class="form-item">',
|
||||
'#suffix' => '<div class="description">' . $this->t('Click this button to automatically generate an API key with the correct permissions. You will be redirected to your BTCPay Server to authorize the connection.') . '</div></div>',
|
||||
];
|
||||
|
||||
$form['store_id'] = [
|
||||
'#type' => 'textfield',
|
||||
'#title' => $this->t('Store ID'),
|
||||
'#description' => $this->t('Your BTCPay Server Store ID. This will be automatically filled when you generate an API key.'),
|
||||
'#default_value' => $this->configuration['store_id'] ?? '',
|
||||
'#required' => TRUE,
|
||||
];
|
||||
|
||||
$form['api_key'] = [
|
||||
'#type' => 'textfield',
|
||||
'#title' => $this->t('API Key'),
|
||||
'#description' => $this->t('Your BTCPay Server API Key. This will be automatically filled when you generate an API key, or you can manually enter one from BTCPay Server under Account > Manage Account > API Keys. Required permissions: View invoices, Create invoice, Modify invoices, Modify stores webhooks.'),
|
||||
'#default_value' => $this->configuration['api_key'] ?? '',
|
||||
'#required' => TRUE,
|
||||
];
|
||||
|
||||
$form['webhook_secret'] = [
|
||||
'#type' => 'textfield',
|
||||
'#title' => $this->t('Webhook Secret'),
|
||||
'#description' => $this->t('Optional: A secret string to validate webhook requests. Will be auto-configured if left empty.'),
|
||||
'#default_value' => $this->configuration['webhook_secret'] ?? '',
|
||||
];
|
||||
|
||||
$form['debug_mode'] = [
|
||||
'#type' => 'checkbox',
|
||||
'#title' => $this->t('Debug Mode'),
|
||||
'#description' => $this->t('Enable verbose logging for debugging. Disable in production to reduce log entries.'),
|
||||
'#default_value' => $this->configuration['debug_mode'] ?? FALSE,
|
||||
];
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
|
||||
parent::submitConfigurationForm($form, $form_state);
|
||||
|
||||
if (!$form_state->getErrors()) {
|
||||
$values = $form_state->getValue($form['#parents']);
|
||||
$this->configuration['server_url'] = $values['server_url'];
|
||||
$this->configuration['api_key'] = $values['api_key'];
|
||||
$this->configuration['store_id'] = $values['store_id'];
|
||||
$this->configuration['webhook_secret'] = $values['webhook_secret'];
|
||||
$this->configuration['debug_mode'] = $values['debug_mode'] ?? FALSE;
|
||||
|
||||
// Ensure offsite gateway settings are correct.
|
||||
$this->configuration['collect_billing_information'] = FALSE;
|
||||
$this->configuration['payment_method_types'] = [];
|
||||
|
||||
// Setup webhook after saving configuration
|
||||
// Get the gateway entity ID from the form state
|
||||
$gateway = $form_state->getFormObject()->getEntity();
|
||||
$gateway_id = $gateway->id();
|
||||
|
||||
if ($this->setupWebhook($gateway_id)) {
|
||||
\Drupal::messenger()->addStatus($this->t('Webhook configured successfully.'));
|
||||
}
|
||||
else {
|
||||
\Drupal::messenger()->addWarning($this->t('Could not configure webhook. Please check the logs.'));
|
||||
}
|
||||
|
||||
// Persist the webhook_id and any configuration changes made during
|
||||
// setupWebhook() to the payment gateway entity immediately.
|
||||
// In the settings form context, ensure we save the configuration.
|
||||
if ($gateway && method_exists($gateway, 'setPluginConfiguration')) {
|
||||
$gateway->setPluginConfiguration($this->getConfiguration());
|
||||
$gateway->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up or updates the webhook for this payment gateway.
|
||||
*
|
||||
* @param string|null $gateway_id
|
||||
* The payment gateway entity ID. If not provided, will try to get from entityId.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if webhook was set up successfully, FALSE otherwise.
|
||||
*/
|
||||
protected function setupWebhook($gateway_id = NULL) {
|
||||
// Get the gateway ID from parameter or from the entity
|
||||
if (!$gateway_id) {
|
||||
$gateway_id = $this->entityId ?? NULL;
|
||||
}
|
||||
|
||||
if (!$gateway_id) {
|
||||
$this->logger->error('Cannot setup webhook: gateway ID not available.');
|
||||
return FALSE;
|
||||
}
|
||||
if (empty($this->configuration['server_url']) || empty($this->configuration['api_key']) || empty($this->configuration['store_id'])) {
|
||||
$this->logger->error('Cannot setup webhook: missing configuration.');
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
try {
|
||||
$webhook_client = new \BTCPayServer\Client\Webhook(
|
||||
$this->configuration['server_url'],
|
||||
$this->configuration['api_key']
|
||||
);
|
||||
|
||||
// Build the webhook URL
|
||||
$webhook_url = \Drupal\Core\Url::fromRoute('commerce_btcpay.notify', [
|
||||
'commerce_payment_gateway' => $gateway_id,
|
||||
], ['absolute' => TRUE])->toString();
|
||||
|
||||
// Check if we have a stored webhook ID
|
||||
$webhook_id = $this->configuration['webhook_id'] ?? NULL;
|
||||
|
||||
// Verify the stored webhook exists; otherwise, clear it so we can search by URL.
|
||||
if ($webhook_id) {
|
||||
try {
|
||||
$webhook_client->getWebhook($this->configuration['store_id'], $webhook_id);
|
||||
if ($this->configuration['debug_mode'] ?? FALSE) {
|
||||
$this->logger->info('Using stored webhook ID: @id', ['@id' => $webhook_id]);
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e) {
|
||||
// Webhook doesn't exist anymore, reset and try to find by URL.
|
||||
if ($this->configuration['debug_mode'] ?? FALSE) {
|
||||
$this->logger->warning('Stored webhook @id not found on server, will search by URL.', ['@id' => $webhook_id]);
|
||||
}
|
||||
$webhook_id = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have a valid webhook ID, try to find an existing webhook by URL.
|
||||
if (!$webhook_id) {
|
||||
try {
|
||||
// Use getStoreWebhooks() which returns a WebhookList object
|
||||
$webhook_list = $webhook_client->getStoreWebhooks($this->configuration['store_id']);
|
||||
$existing_webhooks = $webhook_list->all(); // Returns array of Webhook result objects
|
||||
|
||||
foreach ($existing_webhooks as $existing_webhook) {
|
||||
// Webhook result object has getUrl() and getId() methods
|
||||
$existing_url = $existing_webhook->getUrl();
|
||||
if ($existing_url === $webhook_url) {
|
||||
$webhook_id = $existing_webhook->getId();
|
||||
if ($this->configuration['debug_mode'] ?? FALSE) {
|
||||
$this->logger->info('Found existing webhook by URL with ID @id', ['@id' => $this->safeLogValue($webhook_id)]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e) {
|
||||
// Non-fatal: If listing webhooks fails, we'll fall back to creating one.
|
||||
if ($this->configuration['debug_mode'] ?? FALSE) {
|
||||
$this->logger->warning('Could not list existing webhooks: @error', ['@error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we have a webhook secret
|
||||
if (empty($this->configuration['webhook_secret'])) {
|
||||
$this->configuration['webhook_secret'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
// Specific events we want to listen to
|
||||
$specific_events = [
|
||||
'InvoiceReceivedPayment',
|
||||
'InvoicePaymentSettled',
|
||||
'InvoiceProcessing',
|
||||
'InvoiceExpired',
|
||||
'InvoiceSettled',
|
||||
'InvoiceInvalid',
|
||||
];
|
||||
|
||||
if ($webhook_id) {
|
||||
// Update existing webhook.
|
||||
$webhook_client->updateWebhook(
|
||||
$this->configuration['store_id'],
|
||||
$webhook_url,
|
||||
$webhook_id,
|
||||
$specific_events,
|
||||
TRUE, // enabled
|
||||
TRUE, // automaticRedelivery
|
||||
$this->configuration['webhook_secret']
|
||||
);
|
||||
if ($this->configuration['debug_mode'] ?? FALSE) {
|
||||
$this->logger->info('Updated webhook @id for store @store', [
|
||||
'@id' => $webhook_id,
|
||||
'@store' => $this->configuration['store_id'],
|
||||
]);
|
||||
}
|
||||
// Persist the ID to config to be safe.
|
||||
$this->configuration['webhook_id'] = $webhook_id;
|
||||
}
|
||||
else {
|
||||
// Create new webhook.
|
||||
$result = $webhook_client->createWebhook(
|
||||
$this->configuration['store_id'],
|
||||
$webhook_url,
|
||||
$specific_events,
|
||||
$this->configuration['webhook_secret'],
|
||||
TRUE, // enabled
|
||||
TRUE // automaticRedelivery
|
||||
);
|
||||
|
||||
// Store the webhook ID for future updates
|
||||
$data = is_object($result) && method_exists($result, 'getData') ? $result->getData() : (array) $result;
|
||||
$this->configuration['webhook_id'] = $data['id'] ?? NULL;
|
||||
|
||||
if ($this->configuration['debug_mode'] ?? FALSE) {
|
||||
$this->logger->info('Created webhook @id for store @store', [
|
||||
'@id' => $this->safeLogValue($this->configuration['webhook_id']),
|
||||
'@store' => $this->configuration['store_id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
$this->logger->error('Error setting up webhook: @error', [
|
||||
'@error' => $e->getMessage(),
|
||||
]);
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getInvoiceClient() {
|
||||
if (empty($this->configuration['server_url']) || empty($this->configuration['api_key'])) {
|
||||
$this->logger->error('BTCPay Server URL or API Key not configured.');
|
||||
return NULL;
|
||||
}
|
||||
|
||||
try {
|
||||
return new Invoice(
|
||||
$this->configuration['server_url'],
|
||||
$this->configuration['api_key']
|
||||
);
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
$this->logger->error('Error creating BTCPay API client: @error', ['@error' => $e->getMessage()]);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function createInvoice(OrderInterface $order, array $options = []) {
|
||||
$client = $this->getInvoiceClient();
|
||||
if (!$client) {
|
||||
throw new PaymentGatewayException('Could not initialize BTCPay API client.');
|
||||
}
|
||||
|
||||
$store_id = $this->configuration['store_id'];
|
||||
$amount = $order->getTotalPrice();
|
||||
|
||||
try {
|
||||
// Prepare metadata (don't include orderId or buyerEmail as they're passed separately).
|
||||
$metadata = [
|
||||
'orderNumber' => $order->getOrderNumber(),
|
||||
];
|
||||
|
||||
// Add checkout options.
|
||||
$checkoutOptions = new InvoiceCheckoutOptions();
|
||||
|
||||
if (!empty($options['return_url'])) {
|
||||
$checkoutOptions->setRedirectURL($options['return_url']);
|
||||
}
|
||||
|
||||
// Log the invoice creation attempt.
|
||||
$this->logger->info('Creating BTCPay invoice - Store: @store, Amount: @amount @currency, Order: @order, Email: @email', [
|
||||
'@store' => $store_id,
|
||||
'@amount' => $amount->getNumber(),
|
||||
'@currency' => $amount->getCurrencyCode(),
|
||||
'@order' => $order->id(),
|
||||
'@email' => $order->getEmail() ?: 'none',
|
||||
]);
|
||||
|
||||
// Create the invoice.
|
||||
// Note: orderId and buyerEmail are passed as parameters, not in metadata.
|
||||
$invoice = $client->createInvoice(
|
||||
$store_id,
|
||||
$amount->getCurrencyCode(),
|
||||
PreciseNumber::parseString($amount->getNumber()),
|
||||
$order->id(),
|
||||
$order->getEmail(),
|
||||
$metadata,
|
||||
$checkoutOptions
|
||||
);
|
||||
|
||||
$this->logger->info('BTCPay invoice created successfully: @invoice_id', [
|
||||
'@invoice_id' => $invoice->getData()['id'] ?? 'unknown',
|
||||
]);
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
$this->logger->error('Error creating BTCPay invoice: @error | Type: @type | Trace: @trace', [
|
||||
'@error' => $e->getMessage(),
|
||||
'@type' => get_class($e),
|
||||
'@trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
throw new PaymentGatewayException('Could not create invoice on BTCPay Server: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getInvoice(string $invoiceId) {
|
||||
$client = $this->getInvoiceClient();
|
||||
if (!$client) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
try {
|
||||
$store_id = $this->configuration['store_id'];
|
||||
return $client->getInvoice($store_id, $invoiceId);
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
$this->logger->error('Error getting BTCPay invoice: @error', ['@error' => $e->getMessage()]);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function onReturn(OrderInterface $order, Request $request) {
|
||||
// Get invoice ID from order data.
|
||||
$order_data = $order->getData('btcpay');
|
||||
if (empty($order_data['invoice_id'])) {
|
||||
throw new PaymentGatewayException('Invoice ID missing for this BTCPay transaction.');
|
||||
}
|
||||
|
||||
$invoice_id = $order_data['invoice_id'];
|
||||
|
||||
// Fetch the CURRENT invoice status from BTCPay Server.
|
||||
// This is critical - we don't trust the return URL, we verify the actual status.
|
||||
$this->logger->info('Verifying invoice status from BTCPay Server for invoice: @invoice_id', [
|
||||
'@invoice_id' => $invoice_id,
|
||||
]);
|
||||
|
||||
$invoice = $this->getInvoice($invoice_id);
|
||||
if (!$invoice) {
|
||||
throw new PaymentGatewayException('Could not retrieve invoice from BTCPay Server.');
|
||||
}
|
||||
|
||||
$invoice_data = $invoice->getData();
|
||||
$this->logger->info('Invoice @invoice_id status verified: @status', [
|
||||
'@invoice_id' => $invoice_id,
|
||||
'@status' => $invoice_data['status'],
|
||||
]);
|
||||
|
||||
// Process the payment based on the VERIFIED invoice status.
|
||||
$this->processInvoice($order, $invoice);
|
||||
|
||||
// Check if payment failed and redirect to previous step.
|
||||
if (in_array($invoice_data['status'], ['Expired', 'Invalid'])) {
|
||||
$this->logger->warning('Payment failed for order @order_id, invoice status: @status', [
|
||||
'@order_id' => $order->id(),
|
||||
'@status' => $invoice_data['status'],
|
||||
]);
|
||||
$this->redirectOnPaymentError($order);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function onCancel(OrderInterface $order, Request $request) {
|
||||
$this->messenger()->addMessage(
|
||||
$this->t('Payment was cancelled. You may resume the checkout process when ready.')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function onNotify(Request $request) {
|
||||
// Get the webhook payload.
|
||||
$payload = $request->getContent();
|
||||
$data = json_decode($payload, TRUE);
|
||||
|
||||
// Log incoming webhook (only in debug mode)
|
||||
if ($this->configuration['debug_mode'] ?? FALSE) {
|
||||
$this->logger->info('BTCPay webhook received. Event type: @type', [
|
||||
'@type' => $data['type'] ?? 'unknown',
|
||||
]);
|
||||
}
|
||||
|
||||
// Validate webhook signature FIRST (before any other processing)
|
||||
// Note: Header names may vary in case (BTCPay-Sig vs btcpay-sig)
|
||||
$signature = $this->getWebhookSignature($request);
|
||||
|
||||
if (!$this->validWebhookRequest($signature, $payload)) {
|
||||
$this->logger->error('BTCPay webhook: Failed to validate signature.');
|
||||
throw new PaymentGatewayException('Invalid webhook signature.');
|
||||
}
|
||||
|
||||
// Validate payload structure
|
||||
if (empty($data['invoiceId'])) {
|
||||
$this->logger->error('BTCPay webhook: Invoice ID missing from payload.');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->configuration['debug_mode'] ?? FALSE) {
|
||||
$this->logger->info('Processing webhook for invoice: @invoice_id, event: @event', [
|
||||
'@invoice_id' => $data['invoiceId'],
|
||||
'@event' => $data['type'] ?? 'unknown',
|
||||
]);
|
||||
}
|
||||
|
||||
// Fetch fresh invoice data from BTCPay Server (don't trust webhook payload alone)
|
||||
$invoice = $this->getInvoice($data['invoiceId']);
|
||||
if (!$invoice) {
|
||||
$this->logger->error('BTCPay webhook: Could not retrieve invoice from BTCPay Server. Invoice ID: @invoice_id', [
|
||||
'@invoice_id' => $data['invoiceId'],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get invoice data
|
||||
$invoice_data = $invoice->getData();
|
||||
if ($this->configuration['debug_mode'] ?? FALSE) {
|
||||
$this->logger->debug('BTCPay invoice status: @status, additional status: @additional', [
|
||||
'@status' => $invoice_data['status'] ?? 'unknown',
|
||||
'@additional' => $invoice_data['additionalStatus'] ?? 'none',
|
||||
]);
|
||||
}
|
||||
|
||||
// Get order ID from invoice metadata.
|
||||
if (empty($invoice_data['metadata']['orderId'])) {
|
||||
$this->logger->error('BTCPay webhook: Order ID missing from invoice metadata.');
|
||||
return;
|
||||
}
|
||||
|
||||
$order_id = $invoice_data['metadata']['orderId'];
|
||||
|
||||
// Load the order.
|
||||
$order_storage = \Drupal::entityTypeManager()->getStorage('commerce_order');
|
||||
$order = $order_storage->load($order_id);
|
||||
if (!$order) {
|
||||
$this->logger->error('BTCPay webhook: Order @order_id not found.', ['@order_id' => $order_id]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the invoice based on webhook event type
|
||||
$this->processWebhookEvent($order, $invoice, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process webhook event and update order/payment accordingly.
|
||||
*
|
||||
* @param \Drupal\commerce_order\Entity\OrderInterface $order
|
||||
* The order.
|
||||
* @param \BTCPayServer\Result\Invoice $invoice
|
||||
* The BTCPay invoice.
|
||||
* @param array $webhook_data
|
||||
* The webhook payload data.
|
||||
*/
|
||||
protected function processWebhookEvent(OrderInterface $order, InvoiceResult $invoice, array $webhook_data) {
|
||||
$invoice_data = $invoice->getData();
|
||||
$event_type = $webhook_data['type'] ?? 'unknown';
|
||||
$invoice_status = $invoice_data['status'] ?? 'Unknown';
|
||||
$additional_status = $invoice_data['additionalStatus'] ?? '';
|
||||
|
||||
if ($this->configuration['debug_mode'] ?? FALSE) {
|
||||
$this->logger->info('Processing webhook event @event for order @order_id, invoice status: @status', [
|
||||
'@event' => $event_type,
|
||||
'@order_id' => $order->id(),
|
||||
'@status' => $invoice_status,
|
||||
]);
|
||||
}
|
||||
|
||||
// Determine payment state based on event type and invoice status
|
||||
$payment_state = NULL;
|
||||
$order_message = 'Event: ' . $event_type . ': ';
|
||||
|
||||
switch ($event_type) {
|
||||
case 'InvoiceReceivedPayment':
|
||||
$payment_state = 'authorization';
|
||||
$order_message .= 'Received (partial) payment but waiting for settlement.';
|
||||
break;
|
||||
|
||||
case 'InvoicePaymentSettled':
|
||||
// Only settled if the full invoice is paid
|
||||
if ($invoice_status === 'Expired' && $invoice->isPaidLate()) {
|
||||
$payment_state = 'completed';
|
||||
$order_message = 'Already expired invoice now fully paid and settled.';
|
||||
} else {
|
||||
$payment_state = 'authorization';
|
||||
$order_message .= '(Partial) payment now settled.';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'InvoiceProcessing':
|
||||
$payment_state = 'authorization';
|
||||
$order_message .= 'Received full payment but waiting for settlement.';
|
||||
break;
|
||||
|
||||
case 'InvoiceSettled':
|
||||
$payment_state = 'completed';
|
||||
if ($additional_status === 'PaidOver') {
|
||||
$order_message = 'Overpaid and settled. Please check transaction for refund amount.';
|
||||
} else {
|
||||
$order_message = 'Fully paid and settled.';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'InvoiceExpired':
|
||||
$payment_state = 'authorization_expired';
|
||||
if (!empty($webhook_data['partiallyPaid'])) {
|
||||
$order_message .= 'Invoice expired but received partial payment. Please check transaction details.';
|
||||
} else {
|
||||
$order_message .= 'Invoice expired without payment.';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'InvoiceInvalid':
|
||||
$payment_state = 'authorization_voided';
|
||||
$order_message .= 'Invoice marked as invalid.';
|
||||
break;
|
||||
|
||||
default:
|
||||
$this->logger->warning('Unhandled webhook event type: @type', ['@type' => $event_type]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($payment_state) {
|
||||
// Update or create payment
|
||||
$this->updatePayment($order, $invoice, $payment_state);
|
||||
|
||||
// Log the status update (always log successful updates)
|
||||
$this->logger->notice('Payment status updated for order @order_id: @message', [
|
||||
'@order_id' => $order->id(),
|
||||
'@message' => $order_message,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an invoice and create/update payment.
|
||||
*
|
||||
* @param \Drupal\commerce_order\Entity\OrderInterface $order
|
||||
* The order.
|
||||
* @param \BTCPayServer\Result\Invoice $invoice
|
||||
* The BTCPay invoice.
|
||||
*
|
||||
* @return \Drupal\commerce_payment\Entity\PaymentInterface|null
|
||||
* The payment entity or NULL.
|
||||
*/
|
||||
protected function processInvoice(OrderInterface $order, InvoiceResult $invoice) {
|
||||
$payment_storage = \Drupal::entityTypeManager()->getStorage('commerce_payment');
|
||||
$invoice_id = $invoice->getData()['id'];
|
||||
$status = $invoice->getData()['status'];
|
||||
|
||||
// Debug logging.
|
||||
\Drupal::logger('commerce_btcpay')->debug('processInvoice called for order @order_id, invoice @invoice_id, status @status', [
|
||||
'@order_id' => $order->id(),
|
||||
'@invoice_id' => $invoice_id,
|
||||
'@status' => $status,
|
||||
]);
|
||||
|
||||
// Check if payment already exists.
|
||||
$payments = $payment_storage->loadByProperties([
|
||||
'order_id' => $order->id(),
|
||||
'remote_id' => $invoice_id,
|
||||
]);
|
||||
$payment = reset($payments);
|
||||
|
||||
// Map BTCPay status to Commerce payment state.
|
||||
$payment_state = $this->mapInvoiceStatus($status);
|
||||
|
||||
if ($payment) {
|
||||
// Update existing payment.
|
||||
$payment->setState($payment_state);
|
||||
$payment->setRemoteState($status);
|
||||
$payment->save();
|
||||
\Drupal::logger('commerce_btcpay')->debug('Updated existing payment @payment_id', ['@payment_id' => $payment->id()]);
|
||||
}
|
||||
else {
|
||||
// Create new payment.
|
||||
// Get the payment gateway entity ID from the parent entity.
|
||||
$payment_gateway_id = $this->parentEntity ? $this->parentEntity->id() : NULL;
|
||||
|
||||
\Drupal::logger('commerce_btcpay')->debug('Creating new payment with gateway @gateway_id', ['@gateway_id' => $this->safeLogValue($payment_gateway_id)]);
|
||||
|
||||
$payment = $payment_storage->create([
|
||||
'state' => $payment_state,
|
||||
'amount' => $order->getTotalPrice(),
|
||||
'payment_gateway' => $payment_gateway_id,
|
||||
'order_id' => $order->id(),
|
||||
'remote_id' => $invoice_id,
|
||||
'remote_state' => $status,
|
||||
]);
|
||||
$payment->save();
|
||||
\Drupal::logger('commerce_btcpay')->debug('Created new payment @payment_id', ['@payment_id' => $payment->id()]);
|
||||
}
|
||||
|
||||
return $payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create payment for an order.
|
||||
*
|
||||
* @param \Drupal\commerce_order\Entity\OrderInterface $order
|
||||
* The order.
|
||||
* @param \BTCPayServer\Result\Invoice $invoice
|
||||
* The BTCPay invoice.
|
||||
* @param string $payment_state
|
||||
* The payment state to set.
|
||||
*
|
||||
* @return \Drupal\commerce_payment\Entity\PaymentInterface|null
|
||||
* The payment entity or NULL.
|
||||
*/
|
||||
protected function updatePayment(OrderInterface $order, InvoiceResult $invoice, string $payment_state) {
|
||||
$payment_storage = \Drupal::entityTypeManager()->getStorage('commerce_payment');
|
||||
$invoice_data = $invoice->getData();
|
||||
$invoice_id = $invoice_data['id'];
|
||||
$status = $invoice_data['status'];
|
||||
|
||||
// Check if payment already exists
|
||||
$payments = $payment_storage->loadByProperties([
|
||||
'order_id' => $order->id(),
|
||||
'remote_id' => $invoice_id,
|
||||
]);
|
||||
$payment = reset($payments);
|
||||
|
||||
if ($payment) {
|
||||
// Update existing payment
|
||||
$payment->setState($payment_state);
|
||||
$payment->setRemoteState($status);
|
||||
$payment->save();
|
||||
if ($this->configuration['debug_mode'] ?? FALSE) {
|
||||
$this->logger->debug('Updated existing payment @payment_id to state @state', [
|
||||
'@payment_id' => $payment->id(),
|
||||
'@state' => $payment_state,
|
||||
]);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Create new payment
|
||||
$payment_gateway_id = $this->parentEntity ? $this->parentEntity->id() : NULL;
|
||||
|
||||
$payment = $payment_storage->create([
|
||||
'state' => $payment_state,
|
||||
'amount' => $order->getTotalPrice(),
|
||||
'payment_gateway' => $payment_gateway_id,
|
||||
'order_id' => $order->id(),
|
||||
'remote_id' => $invoice_id,
|
||||
'remote_state' => $status,
|
||||
]);
|
||||
$payment->save();
|
||||
if ($this->configuration['debug_mode'] ?? FALSE) {
|
||||
$this->logger->debug('Created new payment @payment_id with state @state', [
|
||||
'@payment_id' => $payment->id(),
|
||||
'@state' => $payment_state,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely convert a value to string for logging.
|
||||
*
|
||||
* @param mixed $value
|
||||
* The value to convert.
|
||||
*
|
||||
* @return string
|
||||
* The string representation.
|
||||
*/
|
||||
protected function safeLogValue($value): string {
|
||||
if ($value === NULL) {
|
||||
return 'NULL';
|
||||
}
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'TRUE' : 'FALSE';
|
||||
}
|
||||
if (is_array($value) || is_object($value)) {
|
||||
return print_r($value, TRUE);
|
||||
}
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map BTCPay invoice status to Commerce payment state.
|
||||
*
|
||||
* @param string $status
|
||||
* The BTCPay invoice status.
|
||||
*
|
||||
* @return string
|
||||
* The Commerce payment state.
|
||||
*/
|
||||
protected function mapInvoiceStatus(string $status): string {
|
||||
$status_map = [
|
||||
'New' => 'new',
|
||||
'Processing' => 'authorization',
|
||||
'Settled' => 'completed',
|
||||
'Expired' => 'authorization_expired',
|
||||
'Invalid' => 'authorization_voided',
|
||||
];
|
||||
|
||||
return $status_map[$status] ?? 'new';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhook signature from request headers.
|
||||
*
|
||||
* Note: Header names may be case-insensitive depending on the server.
|
||||
*
|
||||
* @param \Symfony\Component\HttpFoundation\Request $request
|
||||
* The request object.
|
||||
*
|
||||
* @return string|null
|
||||
* The signature or NULL if not found.
|
||||
*/
|
||||
protected function getWebhookSignature(Request $request): ?string {
|
||||
// Try different case variations of the header name
|
||||
$signature = $request->headers->get('BTCPay-Sig');
|
||||
if (!$signature) {
|
||||
$signature = $request->headers->get('btcpay-sig');
|
||||
}
|
||||
if (!$signature) {
|
||||
$signature = $request->headers->get('Btcpay-Sig');
|
||||
}
|
||||
return $signature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate webhook request signature.
|
||||
*
|
||||
* @param string|null $signature
|
||||
* The signature from the header.
|
||||
* @param string $payload
|
||||
* The raw webhook payload.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if valid, FALSE otherwise.
|
||||
*/
|
||||
protected function validWebhookRequest(?string $signature, string $payload): bool {
|
||||
if (empty($signature) || empty($this->configuration['webhook_secret'])) {
|
||||
$this->logger->warning('Webhook validation failed: missing signature or secret.');
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
// Use BTCPay SDK's validation method
|
||||
try {
|
||||
return Webhook::isIncomingWebhookRequestValid(
|
||||
$payload,
|
||||
$signature,
|
||||
$this->configuration['webhook_secret']
|
||||
);
|
||||
}
|
||||
catch (\Exception $e) {
|
||||
$this->logger->error('Webhook validation error: @error', ['@error' => $e->getMessage()]);
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to previous checkout step on payment error.
|
||||
*
|
||||
* @param \Drupal\commerce_order\Entity\OrderInterface $order
|
||||
* The order.
|
||||
*/
|
||||
protected function redirectOnPaymentError(OrderInterface $order) {
|
||||
$this->messenger()->addError(
|
||||
$this->t('The payment could not be completed due to an expired or invalid invoice. Please try again or change payment option in the previous step.')
|
||||
);
|
||||
|
||||
/** @var \Drupal\commerce_checkout\Entity\CheckoutFlowInterface $checkout_flow */
|
||||
$checkout_flow = $order->get('checkout_flow')->entity;
|
||||
if ($checkout_flow) {
|
||||
$checkout_flow_plugin = $checkout_flow->getPlugin();
|
||||
$step_id = $this->checkoutOrderManager->getCheckoutStepId($order);
|
||||
$previous_step_id = $checkout_flow_plugin->getPreviousStepId($step_id);
|
||||
$checkout_flow_plugin->redirectToStep($previous_step_id);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -5,7 +5,11 @@ namespace Drupal\commerce_btcpay\PluginForm;
|
||||
use Drupal\commerce_payment\Exception\PaymentGatewayException;
|
||||
use Drupal\commerce_payment\PluginForm\PaymentOffsiteForm as BasePaymentOffsiteForm;
|
||||
use Drupal\Core\Form\FormStateInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
|
||||
/**
|
||||
* Provides the off-site payment form for BTCPay.
|
||||
*/
|
||||
class BtcPayRedirectForm extends BasePaymentOffsiteForm {
|
||||
|
||||
/**
|
||||
@ -13,6 +17,8 @@ class BtcPayRedirectForm extends BasePaymentOffsiteForm {
|
||||
*/
|
||||
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
|
||||
$form = parent::buildConfigurationForm($form, $form_state);
|
||||
|
||||
#\Drupal::messenger()->addMessage('BTCPay redirect form is being built...', 'status');
|
||||
|
||||
/** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */
|
||||
$payment = $this->entity;
|
||||
@ -23,57 +29,49 @@ class BtcPayRedirectForm extends BasePaymentOffsiteForm {
|
||||
/** @var \Drupal\commerce_order\Entity\OrderInterface $order */
|
||||
$order = $payment->getOrder();
|
||||
|
||||
// Simulate an API call failing and throwing an exception, for test purposes.
|
||||
// See PaymentCheckoutTest::testFailedCheckoutWithOffsiteRedirectGet().
|
||||
if ($order->getBillingProfile() && $order->getBillingProfile()->get('address')->family_name == 'TRIGGER FAIL') {
|
||||
throw new PaymentGatewayException('Could not get the redirect URL.');
|
||||
}
|
||||
|
||||
// Create the invoice (payment request) on the BTCPay server.
|
||||
// Create the invoice on BTCPay Server.
|
||||
$options = [
|
||||
'return_url' => $form['#return_url'],
|
||||
'cancel_url' => $form['#cancel_url'],
|
||||
];
|
||||
|
||||
/** @var \Bitpay\Invoice $btcPayInvoice **/
|
||||
if (! $btcPayInvoice = $payment_gateway_plugin->createInvoice($order, $options)) {
|
||||
$this->redirectToPreviousStep();
|
||||
$invoice = $payment_gateway_plugin->createInvoice($order, $options);
|
||||
|
||||
if (!$invoice) {
|
||||
throw new PaymentGatewayException('Failed to create invoice on BTCPay Server.');
|
||||
}
|
||||
|
||||
// Store the remote invoice data on the order.
|
||||
// Store invoice data on the order and payment.
|
||||
$invoice_data = $invoice->getData();
|
||||
$order->setData('btcpay', [
|
||||
'invoice_id' => $btcPayInvoice->getId(),
|
||||
'expiration_time' => $btcPayInvoice->getExpirationTime()->getTimestamp(),
|
||||
'status' => $btcPayInvoice->getStatus(),
|
||||
'invoice_id' => $invoice_data['id'],
|
||||
'checkout_link' => $invoice_data['checkoutLink'],
|
||||
'status' => $invoice_data['status'],
|
||||
'created_time' => $invoice_data['createdTime'] ?? time(),
|
||||
]);
|
||||
$order->save();
|
||||
|
||||
// Update the payment with the remote ID.
|
||||
$payment->setRemoteId($invoice_data['id']);
|
||||
$payment->setRemoteState($invoice_data['status']);
|
||||
$payment->save();
|
||||
|
||||
// Redirect url from payment provider.
|
||||
$redirect_url = $btcPayInvoice->getUrl();
|
||||
// Get the checkout URL.
|
||||
$redirect_url = $invoice_data['checkoutLink'];
|
||||
|
||||
\Drupal::logger('commerce_btcpay')->info('Redirecting to BTCPay checkout: @url', [
|
||||
'@url' => $redirect_url,
|
||||
]);
|
||||
|
||||
$data = [];
|
||||
|
||||
return $this->buildRedirectForm($form, $form_state, $redirect_url, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects to a previous checkout step on error.
|
||||
*
|
||||
* @throws \Drupal\commerce\Response\NeedsRedirectException
|
||||
*/
|
||||
protected function redirectToPreviousStep() {
|
||||
/** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */
|
||||
$payment = $this->entity;
|
||||
|
||||
/** @var \Drupal\commerce_order\Entity\OrderInterface $order */
|
||||
$order = $payment->getOrder();
|
||||
|
||||
/** @var \Drupal\commerce_checkout\Entity\CheckoutFlowInterface $checkout_flow */
|
||||
$checkout_flow = $order->get('checkout_flow')->entity;
|
||||
/** @var \Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowWithPanesInterface $checkout_flow_plugin */
|
||||
$checkout_flow_plugin = $checkout_flow->getPlugin();
|
||||
$step_id = $checkout_flow_plugin->getPane('payment_information')->getStepId();
|
||||
return $checkout_flow_plugin->redirectToStep($step_id);
|
||||
// Use buildRedirectForm to create the redirect.
|
||||
// For GET redirects, we pass the URL and empty data array.
|
||||
return $this->buildRedirectForm(
|
||||
$form,
|
||||
$form_state,
|
||||
$redirect_url,
|
||||
[],
|
||||
self::REDIRECT_GET
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user