From 1d2cbb23a9b84ecd3d991d41dc034f010ce6b04e Mon Sep 17 00:00:00 2001 From: Andreas Tasch Date: Thu, 18 Aug 2022 00:37:22 +0200 Subject: [PATCH] * Add support for ExpiredPaidLate order status. * Register and listen to PaymentSettled webhook events. * Add UpdateManager to process and perform needed updates once. * Add update routines for order states and update Webhook event config on BTCPay Server. * Bump version. --- btcpay-greenfield-for-woocommerce.php | 14 ++++-- readme.txt | 14 +++++- src/Gateway/AbstractGateway.php | 69 ++++++++++++++++++++++----- src/Helper/GreenfieldApiHelper.php | 20 +++++++- src/Helper/GreenfieldApiWebhook.php | 62 +++++++++++++++++++++++- src/Helper/OrderStates.php | 31 ++++++------ src/Helper/UpdateManager.php | 40 ++++++++++++++++ updates/update-1.0.3.php | 36 ++++++++++++++ 8 files changed, 252 insertions(+), 34 deletions(-) create mode 100644 src/Helper/UpdateManager.php create mode 100644 updates/update-1.0.3.php diff --git a/btcpay-greenfield-for-woocommerce.php b/btcpay-greenfield-for-woocommerce.php index d2a6c5a..d51078f 100644 --- a/btcpay-greenfield-for-woocommerce.php +++ b/btcpay-greenfield-for-woocommerce.php @@ -7,10 +7,12 @@ * Author URI: https://btcpayserver.org * Text Domain: btcpay-greenfield-for-woocommerce * Domain Path: /languages - * Version: 1.0.2 + * Version: 1.0.3 * Requires PHP: 7.4 - * Tested up to: 5.9 + * Tested up to: 6.0 * Requires at least: 5.2 + * WC requires at least: 5.0.0 + * WC tested up to: 6.8 */ use BTCPayServer\WC\Admin\Notice; @@ -21,7 +23,8 @@ defined( 'ABSPATH' ) || exit(); -define( 'BTCPAYSERVER_VERSION', '1.0.2' ); +define( 'BTCPAYSERVER_VERSION', '1.0.3' ); +define( 'BTCPAYSERVER_VERSION_KEY', 'btcpay_gf_version' ); define( 'BTCPAYSERVER_PLUGIN_FILE_PATH', plugin_dir_path( __FILE__ ) ); define( 'BTCPAYSERVER_PLUGIN_URL', plugin_dir_url(__FILE__ ) ); define( 'BTCPAYSERVER_PLUGIN_ID', 'btcpay-greenfield-for-woocommerce' ); @@ -35,6 +38,9 @@ public function __construct() { add_action('woocommerce_thankyou_btcpaygf_default', [$this, 'orderStatusThankYouPage'], 10, 1); + // Run the updates. + \BTCPayServer\WC\Helper\UpdateManager::processUpdates(); + if (is_admin()) { // Register our custom global settings page. add_filter( @@ -254,6 +260,7 @@ function init_btcpay_greenfield() { flush_rewrite_rules(false); update_option('btcpaygf_permalinks_flushed', 1); } + }); // Action links on plugin overview. @@ -339,6 +346,7 @@ function init_btcpay_greenfield() { // Installation routine. register_activation_hook( __FILE__, function() { update_option('btcpaygf_permalinks_flushed', 0); + update_option( BTCPAYSERVER_VERSION_KEY, BTCPAYSERVER_VERSION ); }); // Initialize payment gateways and plugin. diff --git a/readme.txt b/readme.txt index 84567df..803e721 100644 --- a/readme.txt +++ b/readme.txt @@ -3,9 +3,9 @@ Contributors: ndeet, kukks, nicolasdorier Donate link: https://btcpayserver.org/donate/ Tags: bitcoin, btcpay, BTCPay Server, btcpayserver, WooCommerce, payment gateway, accept bitcoin, bitcoin plugin, bitcoin payment processor, bitcoin e-commerce, Lightning Network, Litecoin, cryptocurrency Requires at least: 5.2 -Tested up to: 5.9 +Tested up to: 6.0 Requires PHP: 7.4 -Stable tag: 1.0.2 +Stable tag: 1.0.3 License: MIT License URI: https://github.com/btcpayserver/woocommerce-greenfield-plugin/blob/master/license.txt @@ -103,6 +103,16 @@ You'll find extensive documentation and answers to many of your questions on [BT 6. Example of the PoS app you can launch. == Changelog == += 1.0.3 :: 2022-08-17 = +* New order state: Payment received after invoice has been expired. +* Order metadata restructure, also list multiple payments separated. +* Add plugin action links for settings, logs, docs, support. +* Show notice when BTCPay Server is not fully synched yet. +* Add BTCPay Server info to debug log. +* Update Readme with development instructions. +* Docker: Update to latest WP and WC versions. +* Pin BTCPay Server PHP library stable version. + = 1.0.2 :: 2022-04-08 = * Fix plugin meta docblock version update, pump version once more. diff --git a/src/Gateway/AbstractGateway.php b/src/Gateway/AbstractGateway.php index 3a0a493..a8a872d 100644 --- a/src/Gateway/AbstractGateway.php +++ b/src/Gateway/AbstractGateway.php @@ -112,9 +112,15 @@ public function process_payment( $orderId ) { Logger::debug( 'Invoice creation successful, redirecting user.' ); + $url = $invoice->getData()['checkoutLink']; + // Todo: needs testing, support for .onion URLs, see https://github.com/btcpayserver/woocommerce-greenfield-plugin/issues/4 + /* if ( preg_match( "/^([a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.)?[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.onion$/", $_SERVER['SERVER_NAME'] ) ){ + $url = str_replace($this->apiHelper->url, $_SERVER['SERVER_NAME'], $url); + } */ + return [ 'result' => 'success', - 'redirect' => $invoice->getData()['checkoutLink'], + 'redirect' => $url, ]; } } @@ -289,13 +295,39 @@ protected function processOrderStatus(\WC_Order $order, \stdClass $webhookData) switch ($webhookData->type) { case 'InvoiceReceivedPayment': if ($webhookData->afterExpiration) { - if ($order->get_status() === $configuredOrderStates[OrderStates::EXPIRED]) { + $this->updateWCOrderStatus($order, $configuredOrderStates[OrderStates::EXPIRED_PAID_PARTIAL]); + $order->add_order_note(__('Invoice (partial) payment incoming (unconfirmed) after invoice was already expired.', 'btcpay-greenfield-for-woocommerce')); + } else { + // No need to change order status here, only leave a note. + $order->add_order_note(__('Invoice (partial) payment incoming (unconfirmed). Waiting for settlement.', 'btcpay-greenfield-for-woocommerce')); + } + + // Store payment data (exchange rate, address). + $this->updateWCOrderPayments($order); + + break; + case 'InvoicePaymentSettled': + // We can't use $webhookData->afterExpiration here as there is a bug affecting all version prior to + // BTCPay Server v1.7.0.0, see https://github.com/btcpayserver/btcpayserver/issues/ + // Therefore we check if the invoice is in expired or expired paid partial status, instead. + $orderStatus = $order->get_status(); + if ($orderStatus === str_replace('wc-', '', $configuredOrderStates[OrderStates::EXPIRED]) || + $orderStatus === str_replace('wc-', '', $configuredOrderStates[OrderStates::EXPIRED_PAID_PARTIAL]) + ) { + // Check if also the invoice is now fully paid. + if (GreenfieldApiHelper::invoiceIsFullyPaid($webhookData->invoiceId)) { + Logger::debug('Invoice fully paid.'); + $this->updateWCOrderStatus($order, $configuredOrderStates[OrderStates::EXPIRED_PAID_LATE]); + $order->add_order_note(__('Invoice fully settled after invoice was already expired. Needs manual checking.', 'btcpay-greenfield-for-woocommerce')); + //$order->payment_complete(); + } else { + Logger::debug('Invoice NOT fully paid.'); $this->updateWCOrderStatus($order, $configuredOrderStates[OrderStates::EXPIRED_PAID_PARTIAL]); - $order->add_order_note(__('Invoice payment received after invoice was already expired.', 'btcpay-greenfield-for-woocommerce')); + $order->add_order_note(__('(Partial) payment settled but invoice not settled yet (could be more transactions incoming). Needs manual checking.', 'btcpay-greenfield-for-woocommerce')); } } else { // No need to change order status here, only leave a note. - $order->add_order_note(__('Invoice (partial) payment received. Waiting for full payment.', 'btcpay-greenfield-for-woocommerce')); + $order->add_order_note(__('Invoice (partial) payment settled.', 'btcpay-greenfield-for-woocommerce')); } // Store payment data (exchange rate, address). @@ -402,6 +434,7 @@ public function markInvoiceInvalid($invoiceId): void { */ public function updateWCOrderStatus(\WC_Order $order, string $status): void { if ($status !== OrderStates::IGNORE) { + Logger::debug('Updating order status from ' . $order->get_status() . ' to ' . $status); $order->update_status($status); } } @@ -414,17 +447,27 @@ public function updateWCOrderPayments(\WC_Order $order): void { foreach ($allPaymentData as $payment) { // Only continue if the payment method has payments made. - if ((float) $payment->getTotalPaid() > 0.0) { - $paymentMethod = $payment->getPaymentMethod(); - // Update order meta data. - update_post_meta( $order->get_id(), "BTCPay_{$paymentMethod}_destination", $payment->getDestination() ?? '' ); - update_post_meta( $order->get_id(), "BTCPay_{$paymentMethod}_amount", $payment->getAmount() ?? '' ); - update_post_meta( $order->get_id(), "BTCPay_{$paymentMethod}_paid", $payment->getTotalPaid() ?? '' ); - update_post_meta( $order->get_id(), "BTCPay_{$paymentMethod}_networkFee", $payment->getNetworkFee() ?? '' ); - update_post_meta( $order->get_id(), "BTCPay_{$paymentMethod}_rate", $payment->getRate() ?? '' ); + if ((float) $payment->getPaymentMethodPaid() > 0.0) { + $paymentMethodName = $payment->getPaymentMethod(); + // Update order meta data with payment methods and transactions. + update_post_meta( $order->get_id(), "BTCPay_{$paymentMethodName}_total_paid", $payment->getTotalPaid() ?? '' ); + update_post_meta( $order->get_id(), "BTCPay_{$paymentMethodName}_total_amount", $payment->getAmount() ?? '' ); + update_post_meta( $order->get_id(), "BTCPay_{$paymentMethodName}_total_due", $payment->getDue() ?? '' ); + update_post_meta( $order->get_id(), "BTCPay_{$paymentMethodName}_total_fee", $payment->getNetworkFee() ?? '' ); + update_post_meta( $order->get_id(), "BTCPay_{$paymentMethodName}_rate", $payment->getRate() ?? '' ); if ((float) $payment->getRate() > 0.0) { $formattedRate = number_format((float) $payment->getRate(), wc_get_price_decimals(), wc_get_price_decimal_separator(), wc_get_price_thousand_separator()); - update_post_meta( $order->get_id(), "BTCPay_{$paymentMethod}_rateFormatted", $formattedRate ); + update_post_meta( $order->get_id(), "BTCPay_{$paymentMethodName}_rateFormatted", $formattedRate ); + } + + // For each actual payment make a separate entry to make sense of it. + foreach ($payment->getPayments() as $index => $trx) { + update_post_meta( $order->get_id(), "BTCPay_{$paymentMethodName}_{$index}_id", $trx->getTransactionId() ?? '' ); + update_post_meta( $order->get_id(), "BTCPay_{$paymentMethodName}_{$index}_timestamp", $trx->getReceivedTimestamp() ?? '' ); + update_post_meta( $order->get_id(), "BTCPay_{$paymentMethodName}_{$index}_destination", $trx->getDestination() ?? '' ); + update_post_meta( $order->get_id(), "BTCPay_{$paymentMethodName}_{$index}_amount", $trx->getValue() ?? '' ); + update_post_meta( $order->get_id(), "BTCPay_{$paymentMethodName}_{$index}_status", $trx->getStatus() ?? '' ); + update_post_meta( $order->get_id(), "BTCPay_{$paymentMethodName}_{$index}_networkFee", $trx->getFee() ?? '' ); } } } diff --git a/src/Helper/GreenfieldApiHelper.php b/src/Helper/GreenfieldApiHelper.php index f077320..41a1e23 100644 --- a/src/Helper/GreenfieldApiHelper.php +++ b/src/Helper/GreenfieldApiHelper.php @@ -4,6 +4,7 @@ namespace BTCPayServer\WC\Helper; +use BTCPayServer\Client\Invoice; use BTCPayServer\Client\Server; use BTCPayServer\Client\Store; use BTCPayServer\Client\StorePaymentMethod; @@ -38,7 +39,7 @@ public static function getConfig(): array { $key = get_option('btcpay_gf_api_key'); if ($url && $key) { return [ - 'url' => $url, + 'url' => rtrim($url, '/'), 'api_key' => $key, 'store_id' => get_option('btcpay_gf_store_id', null), 'webhook' => get_option('btcpay_gf_webhook', null) @@ -159,4 +160,21 @@ public static function apiCredentialsExist(string $apiUrl, string $apiKey, strin return false; } + /** + * Checks if a given invoice id has status of fully paid (settled) or paid late. + */ + public static function invoiceIsFullyPaid(string $invoiceId): bool { + if ($config = self::getConfig()) { + $client = new Invoice($config['url'], $config['api_key']); + try { + $invoice = $client->getInvoice($config['store_id'], $invoiceId); + return $invoice->isFullyPaid() || $invoice->isPaidLate(); + } catch (\Throwable $e) { + Logger::debug('Exception while checking if invoice settled '. $invoiceId . ': ' . $e->getMessage()); + } + } + + return false; + } + } diff --git a/src/Helper/GreenfieldApiWebhook.php b/src/Helper/GreenfieldApiWebhook.php index ffbf7ec..5a2ee98 100644 --- a/src/Helper/GreenfieldApiWebhook.php +++ b/src/Helper/GreenfieldApiWebhook.php @@ -10,6 +10,7 @@ class GreenfieldApiWebhook { public const WEBHOOK_EVENTS = [ 'InvoiceReceivedPayment', + 'InvoicePaymentSettled', 'InvoiceProcessing', 'InvoiceExpired', 'InvoiceSettled', @@ -64,7 +65,66 @@ public static function registerWebhook(string $apiUrl, $apiKey, $storeId): ?Webh return $webhook; } catch (\Throwable $e) { - Logger::debug('Error fetching existing Webhook from BTCPay Server.'); + Logger::debug('Error creating a new webhook on BTCPay Server instance: ' . $e->getMessage()); + } + + return null; + } + + /** + * Update an existing webhook on BTCPay Server. + */ + public static function updateWebhook( + string $webhookId, + string $webhookUrl, + string $secret, + bool $enabled, + bool $automaticRedelivery, + ?array $events + ): ?WebhookResult { + + if ($config = GreenfieldApiHelper::getConfig()) { + try { + $whClient = new Webhook( $config['url'], $config['api_key'] ); + $webhook = $whClient->updateWebhook( + $config['store_id'], + $webhookUrl, + $webhookId, + $events ?? self::WEBHOOK_EVENTS, + $enabled, + $automaticRedelivery, + $secret + ); + + return $webhook; + } catch (\Throwable $e) { + Logger::debug('Error updating existing Webhook from BTCPay Server: ' . $e->getMessage()); + return null; + } + } else { + Logger::debug('Plugin not configured, aborting updating webhook.'); + } + + return null; + } + + /** + * Load existing webhook data from BTCPay Server, defaults to locally stored webhook. + */ + public static function getWebhook(?string $webhookId): ?WebhookResult { + $existingWebhook = get_option('btcpay_gf_webhook'); + $config = GreenfieldApiHelper::getConfig(); + + try { + $whClient = new Webhook( $config['url'], $config['api_key'] ); + $webhook = $whClient->getWebhook( + $config['store_id'], + $webhookId ?? $existingWebhook['id'], + ); + + return $webhook; + } catch (\Throwable $e) { + Logger::debug('Error fetching existing Webhook from BTCPay Server: ' . $e->getMessage()); } return null; diff --git a/src/Helper/OrderStates.php b/src/Helper/OrderStates.php index 60147fd..abf065d 100644 --- a/src/Helper/OrderStates.php +++ b/src/Helper/OrderStates.php @@ -15,29 +15,32 @@ class OrderStates { const INVALID = 'Invalid'; const EXPIRED = 'Expired'; const EXPIRED_PAID_PARTIAL = 'ExpiredPaidPartial'; + const EXPIRED_PAID_LATE = 'ExpiredPaidLate'; const IGNORE = 'BTCPAY_IGNORE'; public function getDefaultOrderStateMappings(): array { return [ - self::NEW => 'wc-pending', - self::PROCESSING => 'wc-on-hold', - self::SETTLED => self::IGNORE, - self::SETTLED_PAID_OVER => 'wc-processing', - self::INVALID => 'wc-failed', - self::EXPIRED => 'wc-cancelled', - self::EXPIRED_PAID_PARTIAL => 'wc-failed' + self::NEW => 'wc-pending', + self::PROCESSING => 'wc-on-hold', + self::SETTLED => self::IGNORE, + self::SETTLED_PAID_OVER => 'wc-processing', + self::INVALID => 'wc-failed', + self::EXPIRED => 'wc-cancelled', + self::EXPIRED_PAID_PARTIAL => 'wc-failed', + self::EXPIRED_PAID_LATE => 'wc-processing' ]; } public function getOrderStateLabels(): array { return [ - self::NEW => _x('New', 'global_settings', 'btcpay-greenfield-for-woocommerce'), - self::PROCESSING => _x('Paid', 'global_settings', 'btcpay-greenfield-for-woocommerce'), - self::SETTLED => _x('Settled', 'global_settings', 'btcpay-greenfield-for-woocommerce'), - self::SETTLED_PAID_OVER => _x('Settled (paid over)', 'global_settings', 'btcpay-greenfield-for-woocommerce'), - self::INVALID => _x('Invalid', 'global_settings', 'btcpay-greenfield-for-woocommerce'), - self::EXPIRED => _x('Expired', 'global_settings', 'btcpay-greenfield-for-woocommerce'), - self::EXPIRED_PAID_PARTIAL => _x('Expired with partial payment', 'global_settings', 'btcpay-greenfield-for-woocommerce') + self::NEW => _x('New', 'global_settings', 'btcpay-greenfield-for-woocommerce'), + self::PROCESSING => _x('Paid', 'global_settings', 'btcpay-greenfield-for-woocommerce'), + self::SETTLED => _x('Settled', 'global_settings', 'btcpay-greenfield-for-woocommerce'), + self::SETTLED_PAID_OVER => _x('Settled (paid over)', 'global_settings', 'btcpay-greenfield-for-woocommerce'), + self::INVALID => _x('Invalid', 'global_settings', 'btcpay-greenfield-for-woocommerce'), + self::EXPIRED => _x('Expired', 'global_settings', 'btcpay-greenfield-for-woocommerce'), + self::EXPIRED_PAID_PARTIAL => _x('Expired with partial payment', 'global_settings', 'btcpay-greenfield-for-woocommerce'), + self::EXPIRED_PAID_LATE => _x('Expired (paid late)', 'global_settings', 'btcpay-greenfield-for-woocommerce') ]; } diff --git a/src/Helper/UpdateManager.php b/src/Helper/UpdateManager.php new file mode 100644 index 0000000..ec8ab8c --- /dev/null +++ b/src/Helper/UpdateManager.php @@ -0,0 +1,40 @@ + 'update-1.0.3.php' + ]; + + /** + * Runs updates if available or just updates the stored version. + */ + public static function processUpdates() { + + // Check stored version to see if update is needed, will only run once. + $runningVersion = get_option( BTCPAYSERVER_VERSION_KEY, '1.0.2' ); + + if ( version_compare( $runningVersion, BTCPAYSERVER_VERSION, '<' ) ) { + + // Run update scripts if there are any. + foreach ( self::$updates as $updateVersion => $filename ) { + if ( version_compare( $runningVersion, $updateVersion, '<' ) ) { + $file = BTCPAYSERVER_PLUGIN_FILE_PATH . 'updates/' . $filename; + if ( file_exists( $file ) ) { + include $file; + } + $runningVersion = $updateVersion; + update_option( BTCPAYSERVER_VERSION_KEY, $updateVersion ); + Notice::addNotice('success', 'BTCPay Server: successfully ran updates to version ' . $runningVersion, true); + } + } + + update_option( BTCPAYSERVER_VERSION_KEY, BTCPAYSERVER_VERSION ); + } + } + +} diff --git a/updates/update-1.0.3.php b/updates/update-1.0.3.php new file mode 100644 index 0000000..986ef0f --- /dev/null +++ b/updates/update-1.0.3.php @@ -0,0 +1,36 @@ +getId(), + $existingWebhook->getUrl(), + $storedWebhook['secret'], + $existingWebhook->getData()['enabled'], + $existingWebhook->getData()['automaticRedelivery'], + null + ); + \BTCPayServer\WC\Helper\Logger::debug(print_r($updatedWebhook, true), true); + \BTCPayServer\WC\Helper\Logger::debug('Update 1.0.3: Finished updating webhook.', true); +} else { + \BTCPayServer\WC\Helper\Logger::debug('Error fetching existing webhook, aborting update.', true); +}