diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml index 172f66a..f0fb1cd 100644 --- a/.github/workflows/code_style.yml +++ b/.github/workflows/code_style.yml @@ -8,7 +8,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Run PHP CS Fixer uses: docker://oskarstark/php-cs-fixer-ga diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 8d9a267..105f572 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -4,22 +4,21 @@ env: BTCPAY_API_KEY: ${{ secrets.BTCPAY_API_KEY }} BTCPAY_STORE_ID: ${{ secrets.BTCPAY_STORE_ID }} BTCPAY_NODE_URI: ${{ secrets.BTCPAY_NODE_URI }} -on: [ push, pull_request ] +on: [push, pull_request] jobs: phpunit: runs-on: ubuntu-latest strategy: matrix: - php-versions: ['8.0', '8.1'] - phpunit-versions: ['latest'] + php-versions: ["8.0", "8.1"] + phpunit-versions: ["latest"] - steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: - fetch-depth: '0' + fetch-depth: "0" - name: Setup PHP, with composer and extensions uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml index be8c0e0..a750217 100644 --- a/.github/workflows/psalm.yml +++ b/.github/workflows/psalm.yml @@ -9,12 +9,12 @@ jobs: strategy: fail-fast: false matrix: - php-versions: ['8.0'] + php-versions: ["8.0"] steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: - fetch-depth: '0' + fetch-depth: "0" - name: Setup PHP, with composer and extensions uses: shivammathur/setup-php@v2 diff --git a/README.md b/README.md index f544ea8..99a9e39 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ # BTCPay Server Greenfield API PHP client library + This library makes it easier to integrate BTCPay Server in your PHP application. ## Approach + This library takes an opinionated approach to Greenfield API with the aim of making your developer life as easy and convenient as possible. For this reason, we have decided to structure arguments a bit differently, but still allow full and advanced use cases. The general reasoning behind the arguments an API client takes are in this order: + - First the required parameters => method arguments with NULL not allowed - Recommended parameters => method arguments with NULL as default - Optional parameters => arguments with NULL as default @@ -14,11 +17,13 @@ The general reasoning behind the arguments an API client takes are in this order Methods that return a Unix timestamp always end with `Timestamp` like `getReceivedTimestamp()` to avoid format and timezone confusion. These are always in seconds (not milliseconds). ## Features + - No external dependencies. You can just drop this code in your project using composer or without composer. -- Requires PHP 7.3 and up. End-of-life'd versions will not be actively supported. +- Requires PHP 8.0 and up. End-of-life'd versions will not be actively supported. - All calls needed for eCommerce are included, but there are more we still need to add. ## TODO + - convert examples to tests - Getters and setters - Expand beyond the eCommerce related API calls and make this library 100% complete. @@ -28,13 +33,17 @@ Methods that return a Unix timestamp always end with `Timestamp` like `getReceiv ``` composer require btcpayserver/btcpayserver-greenfield-php ``` + If you use some framework or other project you likely are ready to go. If you start from scratch make sure to include Composer autoloader. + ``` require __DIR__ . '/../vendor/autoload.php'; ``` ## How to use without composer (not recommended) + In the `src` directory we have a custom `autoload.php` which you can require and avoid using composer if needed. + ``` // Require the autoload file. require __DIR__ . '/../src/autoload.php'; @@ -52,19 +61,25 @@ try { ``` ## Best practices + - Always use an API key with as little permissions as possible. - If you only interact with specific stores, use an API key that is limited to that store or those stores only. -- When processing an incoming webhook, always load the data fresh using the API as the data may be stale or changed in the meantime. Webhook payloads can be resent on error, so you could be seeing outdated information. By loading the data fresh, you are also protecting yourself from possibly spoofed (fake) requests. +- When processing an incoming webhook, always load the data fresh using the API as the data may be stale or changed in the meantime. Webhook payloads can be resent on error, so you could be seeing outdated information. By loading the data fresh, you are also protecting yourself from possibly spoofed (fake) requests. ## FAQ + ### Where to get the API key from? -The API keys for Greenfield API are *not* on the store level "Access Tokens" anymore. You need to go to your account profile: "My Settings" (user profile icon) -> "API Keys" instead. You can even redirect the users to generate the API keys there. + +The API keys for Greenfield API are _not_ on the store level "Access Tokens" anymore. You need to go to your account profile: "My Settings" (user profile icon) -> "API Keys" instead. You can even redirect the users to generate the API keys there. ## Contribute + We run static analyzer [Psalm](https://psalm.dev/) and [PHP-CS-fixer](https://github.com/FriendsOfPhp/PHP-CS-Fixer) for codestyle when you open a pull-request. Please check if there are any errors and fix them accordingly. ### Codestyle + We use PSR-12 code style to ensure proper formatting and spacing. You can test and format your code using composer commands. Before doing a PR you can run `composer cs-check` and `composer cs-fix` which will run php-cs-fixer. ### Greenfield API coverage + Currently implemented functionality is tracked in [this sheet](https://docs.google.com/spreadsheets/d/1A1tMWYHGVkFWRgqfkW9GSGBRjzKZzsu5XMIW1NLs-xg/edit#gid=0) and will be updated sporadically. Check to see which areas still need work in case you want to contribute. diff --git a/examples/api_key.php b/examples/api_key.php index 60abbf7..2f78345 100644 --- a/examples/api_key.php +++ b/examples/api_key.php @@ -7,8 +7,8 @@ // Fill in with your BTCPay Server data. $apiKey = ''; $host = ''; // e.g. https://your.btcpay-server.tld -$storeId = ''; -$invoiceId = ''; +$email = ''; // e.g. test@example.com +$password = ''; // Get information about store on BTCPay Server. try { @@ -17,3 +17,33 @@ } catch (\Throwable $e) { echo "Error: " . $e->getMessage(); } + +/* +print("\nCreate a new api key (needs server modify permission of used api).\n"); +try { + $client = new Apikey($host, $apiKey); + var_dump($client->createApiKey('api generated', ['btcpay.store.canmodifystoresettings'])); +} catch (\Throwable $e) { + echo "Error: " . $e->getMessage(); +} +*/ +print("\nCreate a new api key for different user. Needs unrestricted access\n"); + +try { + $client = new Apikey($host, $apiKey); + $uKey = $client->createApiKeyForUser($userEmail, 'api generated to be deleted', ['btcpay.store.canmodifystoresettings']); + var_dump($uKey); +} catch (\Throwable $e) { + echo "Error: " . $e->getMessage(); +} + + +print("\nRevoke api key for different user.\n"); + +try { + $client = new Apikey($host, $apiKey); + $uKey = $client->revokeApiKeyForUser($userEmail, $uKey->getData()['apiKey']); + var_dump($uKey); +} catch (\Throwable $e) { + echo "Error: " . $e->getMessage(); +} diff --git a/examples/basic_usage.php b/examples/basic_usage.php index 34e27f8..23d3c2c 100644 --- a/examples/basic_usage.php +++ b/examples/basic_usage.php @@ -17,3 +17,11 @@ } catch (\Throwable $e) { echo "Error: " . $e->getMessage(); } + +// Create a new store. +try { + $client = new Store($host, $apiKey); + var_dump($client->createStore('my new store')); +} catch (\Throwable $e) { + echo "Error: " . $e->getMessage(); +} diff --git a/examples/user_usage.php b/examples/user_usage.php index 2530e57..28c3c42 100644 --- a/examples/user_usage.php +++ b/examples/user_usage.php @@ -49,7 +49,7 @@ public function createUser() } } - public function deleteUser() + public function deleteUser(string $userId) { $userId = ''; @@ -60,6 +60,16 @@ public function deleteUser() echo "Error: " . $e->getMessage(); } } + + public function setUserLock(string $userId, bool $toggle) + { + try { + $client = new User($this->host, $this->apiKey); + var_dump($client->setUserLock($userId, $toggle)); + } catch (\Throwable $e) { + echo "Error: " . $e->getMessage(); + } + } } $users = new Users(); diff --git a/src/Client/ApiKey.php b/src/Client/ApiKey.php index 776b17d..f979896 100644 --- a/src/Client/ApiKey.php +++ b/src/Client/ApiKey.php @@ -77,4 +77,121 @@ public function getCurrent(): ResultApiKey throw $this->getExceptionByStatusCode($method, $url, $response); } } + + /** + * Create a new API key for current user. + * + * @param string $label Visible label on API key overview + * @param array $permissions The permissions array can contain specific store id + * e.g. btcpay.server.canmanageusers:2KxSpc9V5zDWfUbvgYiZuAfka4wUhGF96F75Ao8y4zHP + */ + public function createApikey(?string $label = null, ?array $permissions = null): ResultApiKey + { + $url = $this->getApiUrl() . 'api-keys'; + $headers = $this->getRequestHeaders(); + $method = 'POST'; + + $body = json_encode( + [ + 'label' => $label, + 'permissions' => $permissions + ], + JSON_THROW_ON_ERROR + ); + + $response = $this->getHttpClient()->request($method, $url, $headers, $body); + + if ($response->getStatus() === 200) { + return new ResultApiKey(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR)); + } else { + throw $this->getExceptionByStatusCode($method, $url, $response); + } + } + + /** + * Create a new API key for a user. + * + * @param string $userId Can be user id or email. + * @param string $label Visible label on API key overview + * @param array $permissions The permissions array can contain specific store id + * e.g. btcpay.server.canmanageusers:2KxSpc9V5zDWfUbvgYiZuAfka4wUhGF96F75Ao8y4zHP + */ + public function createApiKeyForUser( + string $idOrMail, + ?string $label = null, + ?array $permissions = null + ): ResultApiKey { + $url = $this->getApiUrl() . 'users/' . urlencode($idOrMail) . '/api-keys'; + $headers = $this->getRequestHeaders(); + $method = 'POST'; + + $body = json_encode( + [ + 'label' => $label, + 'permissions' => $permissions + ], + JSON_THROW_ON_ERROR + ); + + $response = $this->getHttpClient()->request($method, $url, $headers, $body); + + if ($response->getStatus() === 200) { + return new ResultApiKey(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR)); + } else { + throw $this->getExceptionByStatusCode($method, $url, $response); + } + } + + + /** + * Revokes the current API key. + */ + public function revokeCurrentApiKey(): bool + { + $url = $this->getApiUrl() . 'api-keys/current'; + $headers = $this->getRequestHeaders(); + $method = 'DELETE'; + $response = $this->getHttpClient()->request($method, $url, $headers); + + if ($response->getStatus() === 200) { + return true; + } else { + throw $this->getExceptionByStatusCode($method, $url, $response); + } + } + + /** + * Revokes an API key for current user. + */ + public function revokeApiKey(string $apiKey): bool + { + $url = $this->getApiUrl() . 'api-keys/' . urlencode($apiKey); + $headers = $this->getRequestHeaders(); + $method = 'DELETE'; + $response = $this->getHttpClient()->request($method, $url, $headers); + + if ($response->getStatus() === 200) { + return true; + } else { + throw $this->getExceptionByStatusCode($method, $url, $response); + } + } + + + /** + * Revokes the API key of target user. + */ + public function revokeApiKeyForUser(string $idOrMail, string $apiKey): bool + { + $url = $this->getApiUrl() . 'users/' . urlencode($idOrMail) . '/api-keys/' . urlencode($apiKey) ; + $headers = $this->getRequestHeaders(); + $method = 'DELETE'; + $response = $this->getHttpClient()->request($method, $url, $headers); + + if ($response->getStatus() === 200) { + return true; + } else { + throw $this->getExceptionByStatusCode($method, $url, $response); + } + } } diff --git a/src/Client/Invoice.php b/src/Client/Invoice.php index 1fc6d18..26a7c0f 100644 --- a/src/Client/Invoice.php +++ b/src/Client/Invoice.php @@ -178,8 +178,12 @@ public function markInvoiceStatus(string $storeId, string $invoiceId, string $ma } } - public function updateInvoice(string $storeId, string $invoiceId, array $metaData): ResultInvoice - { + public function updateInvoice( + string $storeId, + string $invoiceId, + ?array $metaData = null + ): ResultInvoice { + $url = $this->getApiUrl() . 'stores/' . urlencode( $storeId ) . '/invoices/' . urlencode($invoiceId); diff --git a/src/Client/InvoiceCheckoutOptions.php b/src/Client/InvoiceCheckoutOptions.php index 2d01537..349cf80 100644 --- a/src/Client/InvoiceCheckoutOptions.php +++ b/src/Client/InvoiceCheckoutOptions.php @@ -60,6 +60,7 @@ public static function create( $options->paymentTolerance = $paymentTolerance; $options->redirectURL = $redirectURL; $options->redirectAutomatically = $redirectAutomatically; + $options->requiresRefundEmail = $requiresRefundEmail; $options->defaultLanguage = $defaultLanguage; $options->requiresRefundEmail = $requiresRefundEmail; return $options; diff --git a/src/Client/Store.php b/src/Client/Store.php index 15e36f3..ab2f32d 100644 --- a/src/Client/Store.php +++ b/src/Client/Store.php @@ -8,7 +8,82 @@ class Store extends AbstractClient { - public function getStore($storeId): ResultStore + public function createStore( + string $name, + ?string $website = null, + string $defaultCurrency = 'USD', + int $invoiceExpiration = 900, + int $displayExpirationTimer = 300, + int $monitoringExpiration = 3600, + string $speedPolicy = 'MediumSpeed', + ?string $lightningDescriptionTemplate = null, + int $paymentTolerance = 0, + bool $anyoneCanCreateInvoice = false, + bool $requiresRefundEmail = false, + ?string $checkoutType = 'V1', + ?array $receipt = null, + bool $lightningAmountInSatoshi = false, + bool $lightningPrivateRouteHints = false, + bool $onChainWithLnInvoiceFallback = false, + bool $redirectAutomatically = false, + bool $showRecommendedFee = true, + int $recommendedFeeBlockTarget = 1, + string $defaultLang = 'en', + ?string $customLogo = null, + ?string $customCSS = null, + ?string $htmlTitle = null, + string $networkFeeMode = 'MultiplePaymentsOnly', + bool $payJoinEnabled = false, + bool $lazyPaymentMethods = false, + string $defaultPaymentMethod = 'BTC' + ): ResultStore { + $url = $this->getApiUrl() . 'stores'; + $headers = $this->getRequestHeaders(); + $method = 'POST'; + + $body = json_encode( + [ + "name" => $name, + "website" => $website, + "defaultCurrency" => $defaultCurrency, + "invoiceExpiration" => $invoiceExpiration, + "displayExpirationTimer" => $displayExpirationTimer, + "monitoringExpiration" => $monitoringExpiration, + "speedPolicy" => $speedPolicy, + "lightningDescriptionTemplate" => $lightningDescriptionTemplate, + "paymentTolerance" => $paymentTolerance, + "anyoneCanCreateInvoice" => $anyoneCanCreateInvoice, + "requiresRefundEmail" => $requiresRefundEmail, + "checkoutType" => $checkoutType, + "receipt" => $receipt, + "lightningAmountInSatoshi" => $lightningAmountInSatoshi, + "lightningPrivateRouteHints" => $lightningPrivateRouteHints, + "onChainWithLnInvoiceFallback" => $onChainWithLnInvoiceFallback, + "redirectAutomatically" => $redirectAutomatically, + "showRecommendedFee" => $showRecommendedFee, + "recommendedFeeBlockTarget" => $recommendedFeeBlockTarget, + "defaultLang" => $defaultLang, + "customLogo" => $customLogo, + "customCSS" => $customCSS, + "htmlTitle" => $htmlTitle, + "networkFeeMode" => $networkFeeMode, + "payJoinEnabled" => $payJoinEnabled, + "lazyPaymentMethods" => $lazyPaymentMethods, + "defaultPaymentMethod" => $defaultPaymentMethod + ], + JSON_THROW_ON_ERROR + ); + + $response = $this->getHttpClient()->request($method, $url, $headers, $body); + + if ($response->getStatus() === 200) { + return new ResultStore(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR)); + } else { + throw $this->getExceptionByStatusCode($method, $url, $response); + } + } + + public function getStore(string $storeId): ResultStore { $url = $this->getApiUrl() . 'stores/' . urlencode($storeId); $headers = $this->getRequestHeaders(); diff --git a/src/Client/StoreOnChainWallet.php b/src/Client/StoreOnChainWallet.php index b64b884..4633f54 100644 --- a/src/Client/StoreOnChainWallet.php +++ b/src/Client/StoreOnChainWallet.php @@ -214,6 +214,33 @@ public function getStoreOnChainWalletTransaction( } } + public function updateStoreOnChainWalletTransaction( + string $storeId, + string $cryptoCode, + string $transactionId, + ?string $comment + ): StoreOnChainWalletTransaction { + $url = $this->getApiUrl() . 'stores/' . + urlencode($storeId) . '/payment-methods' . '/OnChain' . '/' . + urlencode($cryptoCode) . '/wallet' . '/transactions' . '/' . + urlencode($transactionId); + + $headers = $this->getRequestHeaders(); + $method = 'PATCH'; + + $body = json_encode(['comment' => $comment], JSON_THROW_ON_ERROR); + + $response = $this->getHttpClient()->request($method, $url, $headers, $body); + + if ($response->getStatus() === 200) { + return new StoreOnChainWalletTransaction( + json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR) + ); + } else { + throw $this->getExceptionByStatusCode($method, $url, $response); + } + } + public function getStoreOnChainWalletUTXOs( string $storeId, string $cryptoCode diff --git a/src/Client/User.php b/src/Client/User.php index 92c4181..001f6f6 100644 --- a/src/Client/User.php +++ b/src/Client/User.php @@ -68,9 +68,9 @@ public function createUser( } } - public function deleteUser(string $userId): bool + public function deleteUser(string $idOrMail): bool { - $url = $this->getApiUrl() . 'users/' . urlencode($userId); + $url = $this->getApiUrl() . 'users/' . urlencode($idOrMail); $headers = $this->getRequestHeaders(); $method = 'DELETE'; $response = $this->getHttpClient()->request($method, $url, $headers); @@ -81,4 +81,26 @@ public function deleteUser(string $userId): bool throw $this->getExceptionByStatusCode($method, $url, $response); } } + + public function setUserLock(string $idOrMail, bool $locked): bool + { + $url = $this->getApiUrl() . 'users/' . urlencode($idOrMail) . '/lock'; + $headers = $this->getRequestHeaders(); + $method = 'POST'; + + $body = json_encode( + [ + 'locked' => $locked, + ], + JSON_THROW_ON_ERROR + ); + + $response = $this->getHttpClient()->request($method, $url, $headers, $body); + + if ($response->getStatus() === 200) { + return true; + } else { + throw $this->getExceptionByStatusCode($method, $url, $response); + } + } } diff --git a/src/Result/AbstractListResult.php b/src/Result/AbstractListResult.php index 42e4d5c..0ad83b0 100644 --- a/src/Result/AbstractListResult.php +++ b/src/Result/AbstractListResult.php @@ -6,7 +6,7 @@ abstract class AbstractListResult extends AbstractResult implements \Countable { - public function count() + public function count(): int { return count($this->getData()); } diff --git a/src/Result/Invoice.php b/src/Result/Invoice.php index 04c682b..8505632 100644 --- a/src/Result/Invoice.php +++ b/src/Result/Invoice.php @@ -98,6 +98,12 @@ public function getCheckoutOptions(): InvoiceCheckoutOptions return $options; } + public function isPaid(): bool + { + $data = $this->getData(); + return $data['status'] === self::STATUS_SETTLED || $data['additionalStatus'] === self::ADDITIONAL_STATUS_PAID_PARTIAL; + } + public function isNew(): bool { $data = $this->getData();