Compare commits

...

5 Commits

Author SHA1 Message Date
ndeet
a3004b382b
Adding new subscription routes and updating subs examples. (#142)
Some checks failed
Code Style / php-cs-fixer (push) Has been cancelled
Static analysis (Psalm) / Psalm (8.0) (push) Has been cancelled
* Adding new subscription routes and updating subs examples.

* Updating github actions due to deprecated nodejs 20.
2026-04-30 11:29:35 +02:00
ndeet
60e6be57f9
Fixes #136 by adding BTCPay >=2.0 parameter. (#141)
Some checks failed
Code Style / php-cs-fixer (push) Has been cancelled
Static analysis (Psalm) / Psalm (8.0) (push) Has been cancelled
2026-01-21 12:31:48 +01:00
Števo Bartko
7d244da111
FIX - php8.4 compatiblity - Explicit nullable param type (#133) 2026-01-21 11:47:07 +01:00
ndeet
e9b01b5266
Change hash comparison, fixes #138 (#140) 2026-01-20 22:51:56 +01:00
ndeet
db79fee3d2
Add subscriptions support. (#139)
* Add subscriptions support.
2026-01-20 22:43:02 +01:00
24 changed files with 1252 additions and 31 deletions

View File

@ -8,7 +8,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Run PHP CS Fixer
uses: docker://oskarstark/php-cs-fixer-ga

View File

@ -12,7 +12,7 @@ jobs:
php-versions: ['8.0']
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: '0'
@ -27,7 +27,7 @@ jobs:
run: echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer dependencies
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}

3
.gitignore vendored
View File

@ -3,4 +3,5 @@
.php-cs-fixer.cache
*.cache
composer.lock
/tests/.env
/tests/.env
/.claude/

View File

@ -43,7 +43,7 @@ class PullPayments
$autoApproveClaims = false;
$startsAt = null;
$expiresAt = null;
$paymentMethods = ['BTC'];
$paymentMethods = ['BTC-CHAIN'];
try {
$client = new PullPayment($this->host, $this->apiKey);
@ -163,7 +163,7 @@ class PullPayments
$pullPaymentId = '';
$destination = '';
$amount = PreciseNumber::parseString('0.000001');
$paymentMethod = '';
$paymentMethod = 'BTC-CHAIN';
try {
$client = new PullPayment($this->host, $this->apiKey);

242
examples/subscriptions.php Normal file
View File

@ -0,0 +1,242 @@
<?php
// Include autoload file.
require __DIR__ . '/../vendor/autoload.php';
// Import Subscriptions client class.
use BTCPayServer\Client\Subscriptions;
// Fill in with your BTCPay Server data.
$apiKey = '';
$host = ''; // e.g. https://your.btcpay-server.tld
$storeId = '';
// Create the subscriptions client.
try {
$client = new Subscriptions($host, $apiKey);
echo "=== BTCPay Server Subscriptions API Examples ===\n\n";
// 1. Create a new offering
echo "1. Creating a new offering...\n";
$offering = $client->createOffering(
$storeId,
'Premium SaaS App',
'https://example.com/success',
[
'category' => 'saas',
'region' => 'us',
'version' => '1.0'
],
[
['id' => 'feature-analytics', 'description' => 'Advanced analytics dashboard'],
['id' => 'feature-support', 'description' => '24/7 priority support'],
['id' => 'feature-api', 'description' => 'Unlimited API access']
]
);
echo "Offering created with ID: " . $offering->getId() . "\n";
echo "App Name: " . $offering->getAppName() . "\n\n";
$offeringId = $offering->getId();
// 2. Create plans for the offering
echo "2. Creating plans for the offering...\n";
// Basic plan
$basicPlan = $client->createOfferingPlan(
$storeId,
$offeringId,
'Basic monthly subscription with essential features',
'USD',
7,
'Basic Plan',
true,
'1.99',
true,
null,
['tier' => 'basic'],
'Monthly',
['feature-analytics']
);
echo "Basic plan created with ID: " . $basicPlan->getId() . "\n";
// Premium plan
$premiumPlan = $client->createOfferingPlan(
$storeId,
$offeringId,
'Premium monthly subscription with all features',
'USD',
7,
'Premium Plan',
true,
'29.99',
true,
14,
['tier' => 'premium'],
'Monthly',
['feature-analytics', 'feature-support', 'feature-api']
);
echo "Premium plan created with ID: " . $premiumPlan->getId() . "\n\n";
$basicPlanId = $basicPlan->getId();
$premiumPlanId = $premiumPlan->getId();
// 2b. Update the offering
echo "2b. Updating the offering...\n";
$updatedOffering = $client->updateOffering(
$storeId,
$offeringId,
'Premium SaaS App v2',
'https://example.com/success-v2',
['category' => 'saas', 'region' => 'eu', 'version' => '2.0']
);
echo "Offering updated: " . $updatedOffering->getAppName() . "\n\n";
// 2c. Update a plan
echo "2c. Updating the basic plan...\n";
$updatedPlan = $client->updateOfferingPlan(
$storeId,
$offeringId,
$basicPlanId,
'Updated basic monthly subscription',
'USD',
7,
'Basic Plan v2',
null,
'2.99'
);
echo "Plan updated: " . $updatedPlan->getName() . " - " . $updatedPlan->getPrice() . " " . $updatedPlan->getCurrency() . "\n\n";
// 3. Get all offerings for the store
echo "3. Getting all offerings for the store...\n";
$offerings = $client->getOfferings($storeId);
foreach ($offerings->all() as $off) {
echo "- Offering: " . $off->getAppName() . " (ID: " . $off->getId() . ")\n";
foreach ($off->getPlans() as $plan) {
echo " - Plan: " . $plan->getName() . " - " . $plan->getPrice() . " " . $plan->getCurrency() . "/" . $plan->getRecurringType() . "\n";
}
}
echo "\n";
// 4. Get a specific offering
echo "4. Getting specific offering details...\n";
$specificOffering = $client->getOffering($storeId, $offeringId);
echo "Offering: " . $specificOffering->getAppName() . "\n";
echo "Success URL: " . $specificOffering->getSuccessRedirectUrl() . "\n";
echo "Number of plans: " . count($specificOffering->getPlans()) . "\n\n";
// 5. Get a specific plan
echo "5. Getting specific plan details...\n";
$specificPlan = $client->getOfferingPlan($storeId, $offeringId, $basicPlanId);
echo "Plan: " . $specificPlan->getName() . "\n";
echo "Price: " . $specificPlan->getPrice() . " " . $specificPlan->getCurrency() . "\n";
echo "Trial Days: " . $specificPlan->getTrialDays() . "\n";
echo "Features: " . implode(', ', $specificPlan->getFeatures()) . "\n\n";
// 6. Create a plan checkout session
echo "6. Creating a plan checkout session...\n";
$checkout = $client->createPlanCheckout(
$storeId,
$offeringId,
$basicPlanId,
null, // If the customer already exists on BTCPay, fill the email or other id here.
60,
'SoftMigration',
['source' => 'web'],
['campaign' => 'summer2026'],
['flow' => 'new_signup'],
false,
null, // You can override the plan price here if you want to force more credit or custom amount.
'https://example.com/welcome',
'test@example.com' // This is optional and will prefill the checkout page with the email.
);
echo "Checkout created with ID: " . $checkout->getId() . "\n";
echo "Checkout URL: " . $checkout->getUrl() . "\n";
echo "Is Trial: " . ($checkout->isTrial() ? 'Yes' : 'No') . "\n";
echo "New Subscriber: " . ($checkout->isNewSubscriber() ? 'Yes' : 'No') . "\n\n";
$checkoutId = $checkout->getId();
// 7. Get plan checkout details
echo "7. Getting plan checkout details...\n";
$checkoutDetails = $client->getPlanCheckout($checkoutId);
echo "Checkout ID: " . $checkoutDetails->getId() . "\n";
echo "Plan: " . $checkoutDetails->getPlan()->getName() . "\n";
$subscriber = $checkoutDetails->getSubscriber();
if ($subscriber && $subscriber->getCustomer()->getIdentities()) {
echo "Subscriber Email: " . ($subscriber->getCustomer()->getIdentities()['Email'] ?? 'N/A') . "\n";
}
echo "\n";
// 8. Subscriber management examples
/*
// Fill these variables with actual values to test subscriber operations
$offeringId = ''; // e.g. "offering_GFbMSBpybM6i5uEiqc"
$customerSelector = ''; // e.g. "ps_N71XxcPDnKNgNDxKHZ" or customer email
$suspensionReason = 'User requested cancellation';
if (!empty($storeId) && !empty($offeringId) && !empty($customerSelector)) {
try {
// Get subscriber details
echo "8. Getting subscriber details...\n";
$subscriber = $client->getSubscriber($storeId, $offeringId, $customerSelector);
echo "Customer ID: " . $subscriber->getCustomer()->getId() . "\n";
echo "Active: " . ($subscriber->isActive() ? 'Yes' : 'No') . "\n";
echo "Phase: " . $subscriber->getPhase() . "\n";
echo "Created: " . date('Y-m-d H:i:s', $subscriber->getCreated()) . "\n";
echo "\n";
// Suspend subscriber
if (!empty($suspensionReason)) {
echo "9. Suspending subscriber...\n";
$client->suspendSubscriber($storeId, $offeringId, $customerSelector, $suspensionReason);
echo "Subscriber suspended successfully!\n";
// Check status after suspension
$suspendedSubscriber = $client->getSubscriber($storeId, $offeringId, $customerSelector);
echo "Status after suspension: " . ($suspendedSubscriber->isActive() ? 'Active' : 'Suspended') . "\n";
echo "Suspension reason: " . ($suspendedSubscriber->getSuspensionReason() ?? 'N/A') . "\n\n";
// Update subscriber dates
echo "9b. Updating subscriber dates...\n";
$updatedSubscriber = $client->updateSubscriberDates(
$storeId,
$offeringId,
$customerSelector,
null,
time() + (30 * 24 * 60 * 60) // 30 days from now
);
echo "Subscriber expiration updated\n";
echo "Scheduled plan: " . ($updatedSubscriber->getScheduledPlan() ? $updatedSubscriber->getScheduledPlan()->getName() : 'None') . "\n";
echo "Scheduled plan activates at: " . ($updatedSubscriber->getScheduledPlanActivatesAt() ? date('Y-m-d H:i:s', $updatedSubscriber->getScheduledPlanActivatesAt()) : 'N/A') . "\n\n";
// Unsuspend subscriber
echo "10. Unsuspending subscriber...\n";
$client->unsuspendSubscriber($storeId, $offeringId, $customerSelector);
echo "Subscriber unsuspended successfully!\n";
// Check status after unsuspending
$reactivatedSubscriber = $client->getSubscriber($storeId, $offeringId, $customerSelector);
echo "Status after unsuspending: " . ($reactivatedSubscriber->isActive() ? 'Active' : 'Suspended') . "\n";
echo "Suspension reason: " . ($reactivatedSubscriber->getSuspensionReason() ?? 'N/A') . "\n\n";
}
// Delete subscriber
echo "11. Deleting subscriber...\n";
$client->deleteSubscriber($storeId, $offeringId, $customerSelector);
echo "Subscriber deleted successfully!\n\n";
} catch (\Throwable $e) {
echo "Error in subscriber management: " . $e->getMessage() . "\n";
}
} else {
echo "8. Subscriber management examples skipped - please fill in storeId, offeringId, and customerSelector variables\n";
}
*/
echo "=== Examples completed successfully! ===\n";
} catch (\Throwable $e) {
echo "Error: " . $e->getMessage() . "\n";
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
}

View File

@ -22,7 +22,7 @@ class AbstractClient
/** @var ClientInterface */
private $httpClient;
public function __construct(string $baseUrl, string $apiKey, ClientInterface $client = null)
public function __construct(string $baseUrl, string $apiKey, ?ClientInterface $client = null)
{
$this->baseUrl = rtrim($baseUrl, '/');
$this->apiKey = $apiKey;

View File

@ -118,8 +118,8 @@ class Invoice extends AbstractClient
public function getAllInvoices(
string $storeId,
int $take = null,
int $skip = null
?int $take = null,
?int $skip = null
): InvoiceList {
return $this->getAllInvoicesWithFilter($storeId, null, null, null, null, null, $take, $skip);
}
@ -127,8 +127,8 @@ class Invoice extends AbstractClient
public function getInvoicesByOrderIds(
string $storeId,
array $orderIds,
int $take = null,
int $skip = null
?int $take = null,
?int $skip = null
): InvoiceList {
return $this->getAllInvoicesWithFilter($storeId, $orderIds, null, null, null, null, $take, $skip);
}
@ -136,8 +136,8 @@ class Invoice extends AbstractClient
public function getInvoicesByText(
string $storeId,
string $text,
int $take = null,
int $skip = null
?int $take = null,
?int $skip = null
): InvoiceList {
return $this->getAllInvoicesWithFilter($storeId, null, $text, null, null, null, $take, $skip);
}
@ -145,8 +145,8 @@ class Invoice extends AbstractClient
public function getInvoicesByStatus(
string $storeId,
array $status,
int $take = null,
int $skip = null
?int $take = null,
?int $skip = null
): InvoiceList {
return $this->getAllInvoicesWithFilter($storeId, null, null, $status, null, null, $take, $skip);
}
@ -154,8 +154,8 @@ class Invoice extends AbstractClient
public function getInvoicesByStartDate(
string $storeId,
int $startDate,
int $take = null,
int $skip = null
?int $take = null,
?int $skip = null
): InvoiceList {
return $this->getAllInvoicesWithFilter($storeId, null, null, null, $startDate, null, $take, $skip);
}
@ -163,8 +163,8 @@ class Invoice extends AbstractClient
public function getInvoicesByEndDate(
string $storeId,
int $endDate,
int $take = null,
int $skip = null
?int $take = null,
?int $skip = null
): InvoiceList {
return $this->getAllInvoicesWithFilter($storeId, null, null, null, null, $endDate, $take, $skip);
}
@ -174,13 +174,13 @@ class Invoice extends AbstractClient
*/
public function getAllInvoicesWithFilter(
string $storeId,
array $filterByOrderIds = null,
string $filterByText = null,
array $filterByStatus = null,
int $filterByStartDate = null,
int $filterByEndDate = null,
int $take = null,
int $skip = null
?array $filterByOrderIds = null,
?string $filterByText = null,
?array $filterByStatus = null,
?int $filterByStartDate = null,
?int $filterByEndDate = null,
?int $take = null,
?int $skip = null
): InvoiceList {
$url = $this->getApiUrl() . 'stores/' . urlencode($storeId) . '/invoices?';
if ($filterByOrderIds !== null) {

View File

@ -244,6 +244,7 @@ class PullPayment extends AbstractClient
'destination' => $destination,
'amount' => $amount->__toString(),
'paymentMethod' => $paymentMethod,
'payoutMethodId' => $paymentMethod, // BTCPay 2.0.0 compatibilty
],
JSON_THROW_ON_ERROR
);

View File

@ -10,6 +10,8 @@ use BTCPayServer\Result\StorePaymentMethodLightningNetwork as ResultStorePayment
* Handles a stores LightningNetwork payment methods.
*
* @see https://docs.btcpayserver.org/API/Greenfield/v1/#tag/Store-Payment-Methods-(Lightning-Network)
*
* @deprecated with BTCPay 2.0. Use \BTCPayServer\Client\StorePaymentMethod->getPaymentMethods() instead.
*/
class StorePaymentMethodLightningNetwork extends AbstractStorePaymentMethodClient
{

View File

@ -10,6 +10,8 @@ use BTCPayServer\Result\StorePaymentMethodOnChain as ResultStorePaymentMethodOnC
* Handles stores on chain payment methods.
*
* @see https://docs.btcpayserver.org/API/Greenfield/v1/#tag/Store-Payment-Methods-(On-Chain)
*
* @deprecated with BTCPay 2.0. Use \BTCPayServer\Client\StorePaymentMethod->getPaymentMethods() instead.
*/
class StorePaymentMethodOnChain extends AbstractStorePaymentMethodClient
{
@ -133,7 +135,7 @@ class StorePaymentMethodOnChain extends AbstractStorePaymentMethodClient
string $storeId,
string $cryptoCode,
string $derivationScheme,
string $accountKeyPath = null
?string $accountKeyPath = null
): array {
// todo: add offset + amount query parameters + check structure of derivationScheme etc.

View File

@ -97,7 +97,7 @@ class StoreRate extends AbstractClient
public function getRates(
string $storeId,
array $currencyPairs = null
?array $currencyPairs = null
): StoreRateList {
$url = $this->getApiUrl() . 'stores/' . urlencode($storeId) . '/rates?';
$headers = $this->getRequestHeaders();

View File

@ -0,0 +1,493 @@
<?php
declare(strict_types=1);
namespace BTCPayServer\Client;
use BTCPayServer\Result\Credit;
use BTCPayServer\Result\Offering;
use BTCPayServer\Result\OfferingList;
use BTCPayServer\Result\OfferingPlan;
use BTCPayServer\Result\PlanCheckout;
use BTCPayServer\Result\PortalSession;
use BTCPayServer\Result\Subscriber;
/**
* Handles subscriptions operations.
*
* @see https://docs.btcpayserver.org/API/Greenfield/v1/#tag/Subscriptions
*/
class Subscriptions extends AbstractClient
{
// Offering endpoints
public function getOffering(string $storeId, string $offeringId): Offering
{
$url = $this->getApiUrl() . 'stores/' . urlencode($storeId) . '/offerings/' . urlencode($offeringId);
$headers = $this->getRequestHeaders();
$method = 'GET';
$response = $this->getHttpClient()->request($method, $url, $headers);
if ($response->getStatus() === 200) {
return new Offering(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
public function getOfferings(string $storeId): OfferingList
{
$url = $this->getApiUrl() . 'stores/' . urlencode($storeId) . '/offerings';
$headers = $this->getRequestHeaders();
$method = 'GET';
$response = $this->getHttpClient()->request($method, $url, $headers);
if ($response->getStatus() === 200) {
return new OfferingList(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
public function createOffering(
string $storeId,
?string $appName = null,
?string $successRedirectUrl = null,
?array $metadata = null,
?array $features = null
): Offering {
$url = $this->getApiUrl() . 'stores/' . urlencode($storeId) . '/offerings';
$headers = $this->getRequestHeaders();
$method = 'POST';
$body = json_encode(
[
'appName' => $appName,
'successRedirectUrl' => $successRedirectUrl,
'metadata' => $metadata,
'features' => $features
],
JSON_THROW_ON_ERROR
);
$response = $this->getHttpClient()->request($method, $url, $headers, $body);
if ($response->getStatus() === 200 || $response->getStatus() === 201) {
return new Offering(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
public function updateOffering(
string $storeId,
string $offeringId,
?string $appName = null,
?string $successRedirectUrl = null,
?array $metadata = null,
?array $features = null
): Offering {
$url = $this->getApiUrl() . 'stores/' . urlencode($storeId) . '/offerings/' . urlencode($offeringId);
$headers = $this->getRequestHeaders();
$method = 'PUT';
$body = json_encode(
[
'appName' => $appName,
'successRedirectUrl' => $successRedirectUrl,
'metadata' => $metadata,
'features' => $features
],
JSON_THROW_ON_ERROR
);
$response = $this->getHttpClient()->request($method, $url, $headers, $body);
if ($response->getStatus() === 200) {
return new Offering(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
// Plan endpoints
public function createOfferingPlan(
string $storeId,
string $offeringId,
?string $description = null,
?string $currency = null,
?int $gracePeriodDays = null,
?string $name = null,
?bool $optimisticActivation = null,
?string $price = null,
?bool $renewable = null,
?int $trialDays = null,
?array $metadata = null,
?string $recurringType = null,
?array $features = null
): OfferingPlan {
$url = $this->getApiUrl() . 'stores/' . urlencode($storeId) . '/offerings/' . urlencode($offeringId) . '/plans';
$headers = $this->getRequestHeaders();
$method = 'POST';
$body = json_encode(
[
'description' => $description,
'currency' => $currency,
'gracePeriodDays' => $gracePeriodDays,
'name' => $name,
'optimisticActivation' => $optimisticActivation,
'price' => $price,
'renewable' => $renewable,
'trialDays' => $trialDays,
'metadata' => $metadata,
'recurringType' => $recurringType,
'features' => $features
],
JSON_THROW_ON_ERROR
);
$response = $this->getHttpClient()->request($method, $url, $headers, $body);
if ($response->getStatus() === 200 || $response->getStatus() === 201) {
return new OfferingPlan(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
public function getOfferingPlan(string $storeId, string $offeringId, string $planId): OfferingPlan
{
$url = $this->getApiUrl() . 'stores/' . urlencode($storeId) . '/offerings/' . urlencode($offeringId) . '/plans/' . urlencode($planId);
$headers = $this->getRequestHeaders();
$method = 'GET';
$response = $this->getHttpClient()->request($method, $url, $headers);
if ($response->getStatus() === 200) {
return new OfferingPlan(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
public function updateOfferingPlan(
string $storeId,
string $offeringId,
string $planId,
?string $description = null,
?string $currency = null,
?int $gracePeriodDays = null,
?string $name = null,
?bool $optimisticActivation = null,
?string $price = null,
?bool $renewable = null,
?int $trialDays = null,
?array $metadata = null,
?string $recurringType = null,
?array $features = null
): OfferingPlan {
$url = $this->getApiUrl() . 'stores/' . urlencode($storeId) . '/offerings/' . urlencode($offeringId) . '/plans/' . urlencode($planId);
$headers = $this->getRequestHeaders();
$method = 'PUT';
$body = json_encode(
[
'description' => $description,
'currency' => $currency,
'gracePeriodDays' => $gracePeriodDays,
'name' => $name,
'optimisticActivation' => $optimisticActivation,
'price' => $price,
'renewable' => $renewable,
'trialDays' => $trialDays,
'metadata' => $metadata,
'recurringType' => $recurringType,
'features' => $features
],
JSON_THROW_ON_ERROR
);
$response = $this->getHttpClient()->request($method, $url, $headers, $body);
if ($response->getStatus() === 200) {
return new OfferingPlan(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
// Subscriber endpoints
public function getSubscriber(string $storeId, string $offeringId, string $customerSelector): Subscriber
{
$url = $this->getApiUrl() . 'stores/' . urlencode($storeId) . '/offerings/' . urlencode($offeringId) . '/subscribers/' . urlencode($customerSelector);
$headers = $this->getRequestHeaders();
$method = 'GET';
$response = $this->getHttpClient()->request($method, $url, $headers);
if ($response->getStatus() === 200) {
return new Subscriber(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
public function deleteSubscriber(string $storeId, string $offeringId, string $customerSelector): void
{
$url = $this->getApiUrl() . 'stores/' . urlencode($storeId) . '/offerings/' . urlencode($offeringId) . '/subscribers/' . urlencode($customerSelector);
$headers = $this->getRequestHeaders();
$method = 'DELETE';
$response = $this->getHttpClient()->request($method, $url, $headers);
if ($response->getStatus() !== 204) {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
public function updateSubscriberDates(
string $storeId,
string $offeringId,
string $customerSelector,
?int $startDate = null,
?int $expirationDate = null
): Subscriber {
$url = $this->getApiUrl() . 'stores/' . urlencode($storeId) . '/offerings/' . urlencode($offeringId) . '/subscribers/' . urlencode($customerSelector) . '/dates';
$headers = $this->getRequestHeaders();
$method = 'PUT';
$body = json_encode(
[
'startDate' => $startDate,
'expirationDate' => $expirationDate
],
JSON_THROW_ON_ERROR
);
$response = $this->getHttpClient()->request($method, $url, $headers, $body);
if ($response->getStatus() === 200) {
return new Subscriber(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
public function suspendSubscriber(string $storeId, string $offeringId, string $customerSelector, string $reason): Subscriber
{
$url = $this->getApiUrl() . 'stores/' . urlencode($storeId) . '/offerings/' . urlencode($offeringId) . '/subscribers/' . urlencode($customerSelector) . '/suspend';
$headers = $this->getRequestHeaders();
$method = 'POST';
$body = json_encode(
[
'reason' => $reason
],
JSON_THROW_ON_ERROR
);
$response = $this->getHttpClient()->request($method, $url, $headers, $body);
if ($response->getStatus() === 200) {
return new Subscriber(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
public function unsuspendSubscriber(string $storeId, string $offeringId, string $customerSelector): Subscriber
{
$url = $this->getApiUrl() . 'stores/' . urlencode($storeId) . '/offerings/' . urlencode($offeringId) . '/subscribers/' . urlencode($customerSelector) . '/unsuspend';
$headers = $this->getRequestHeaders();
$method = 'POST';
$response = $this->getHttpClient()->request($method, $url, $headers);
if ($response->getStatus() === 200) {
return new Subscriber(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
// Credit endpoints
public function getCredit(string $storeId, string $offeringId, string $customerSelector, string $currency): Credit
{
$url = $this->getApiUrl() . 'stores/' . urlencode($storeId) . '/offerings/' . urlencode($offeringId) . '/subscribers/' . urlencode($customerSelector) . '/credits/' . urlencode($currency);
$headers = $this->getRequestHeaders();
$method = 'GET';
$response = $this->getHttpClient()->request($method, $url, $headers);
if ($response->getStatus() === 200) {
return new Credit(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
public function updateCredit(
string $storeId,
string $offeringId,
string $customerSelector,
string $currency,
?string $credit = null,
?string $charge = null,
?string $description = null,
?bool $allowOverdraft = null
): Credit {
$url = $this->getApiUrl() . 'stores/' . urlencode($storeId) . '/offerings/' . urlencode($offeringId) . '/subscribers/' . urlencode($customerSelector) . '/credits/' . urlencode($currency);
$headers = $this->getRequestHeaders();
$method = 'POST';
$body = json_encode(
[
'credit' => $credit,
'charge' => $charge,
'description' => $description,
'allowOverdraft' => $allowOverdraft
],
JSON_THROW_ON_ERROR
);
$response = $this->getHttpClient()->request($method, $url, $headers, $body);
if ($response->getStatus() === 200) {
return new Credit(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
// Plan checkout endpoints
public function getPlanCheckout(string $checkoutId): PlanCheckout
{
$url = $this->getApiUrl() . 'plan-checkout/' . urlencode($checkoutId);
$headers = $this->getRequestHeaders();
$method = 'GET';
$response = $this->getHttpClient()->request($method, $url, $headers);
if ($response->getStatus() === 200) {
return new PlanCheckout(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
public function proceedPlanCheckout(string $checkoutId, ?string $email = null): PlanCheckout
{
$url = $this->getApiUrl() . 'plan-checkout/' . urlencode($checkoutId);
$headers = $this->getRequestHeaders();
$method = 'POST';
$params = [];
if ($email !== null) {
$params['email'] = $email;
}
if (!empty($params)) {
$url .= '?' . http_build_query($params);
}
$response = $this->getHttpClient()->request($method, $url, $headers);
if ($response->getStatus() === 200) {
return new PlanCheckout(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
public function createPlanCheckout(
string $storeId,
string $offeringId,
string $planId,
?string $customerSelector = null,
?int $durationMinutes = null,
?string $onPayBehavior = null,
?array $newSubscriberMetadata = null,
?array $invoiceMetadata = null,
?array $metadata = null,
?bool $isTrial = null,
?string $creditPurchase = null,
?string $successRedirectLink = null,
?string $newSubscriberEmail = null
): PlanCheckout {
$url = $this->getApiUrl() . 'plan-checkout';
$headers = $this->getRequestHeaders();
$method = 'POST';
$body = json_encode(
[
'storeId' => $storeId,
'offeringId' => $offeringId,
'planId' => $planId,
'customerSelector' => $customerSelector,
'durationMinutes' => $durationMinutes,
'onPayBehavior' => $onPayBehavior,
'newSubscriberMetadata' => $newSubscriberMetadata,
'invoiceMetadata' => $invoiceMetadata,
'metadata' => $metadata,
'isTrial' => $isTrial,
'creditPurchase' => $creditPurchase,
'successRedirectLink' => $successRedirectLink,
'newSubscriberEmail' => $newSubscriberEmail
],
JSON_THROW_ON_ERROR
);
$response = $this->getHttpClient()->request($method, $url, $headers, $body);
if ($response->getStatus() === 200) {
return new PlanCheckout(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
// Portal session endpoints
public function createPortalSession(
string $storeId,
string $offeringId,
string $customerSelector,
?int $durationMinutes = null
): PortalSession {
$url = $this->getApiUrl() . 'subscriber-portal';
$headers = $this->getRequestHeaders();
$method = 'POST';
$body = json_encode(
[
'storeId' => $storeId,
'offeringId' => $offeringId,
'customerSelector' => $customerSelector,
'durationMinutes' => $durationMinutes
],
JSON_THROW_ON_ERROR
);
$response = $this->getHttpClient()->request($method, $url, $headers, $body);
if ($response->getStatus() === 200) {
return new PortalSession(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
public function getPortalSession(string $portalSessionId): PortalSession
{
$url = $this->getApiUrl() . 'subscriber-portal/' . urlencode($portalSessionId);
$headers = $this->getRequestHeaders();
$method = 'GET';
$response = $this->getHttpClient()->request($method, $url, $headers);
if ($response->getStatus() === 200) {
return new PortalSession(json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR));
} else {
throw $this->getExceptionByStatusCode($method, $url, $response);
}
}
}

View File

@ -238,7 +238,7 @@ class Webhook extends AbstractClient
if ($requestBody && $btcpaySigHeader) {
$expectedHeader = 'sha256=' . hash_hmac('sha256', $requestBody, $secret);
if ($expectedHeader === $btcpaySigHeader) {
if (hash_equals($expectedHeader, $btcpaySigHeader)) {
return true;
}
}

View File

@ -6,7 +6,7 @@ namespace BTCPayServer\Exception;
class BTCPayException extends \RuntimeException
{
public function __construct(string $message, int $code, \Throwable $previous = null)
public function __construct(string $message, int $code, ?\Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}

View File

@ -6,7 +6,7 @@ namespace BTCPayServer\Result;
abstract class AbstractStorePaymentMethodResult extends AbstractResult
{
public function __construct(array $data, string $paymentMethod = null)
public function __construct(array $data, ?string $paymentMethod = null)
{
// Temporary workaround until the api provides paymentMethod.
if (!isset($data['paymentMethod'])) {

18
src/Result/Credit.php Normal file
View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace BTCPayServer\Result;
class Credit extends AbstractResult
{
public function getCurrency(): string
{
return $this->getData()['currency'];
}
public function getValue(): string
{
return $this->getData()['value'];
}
}

33
src/Result/Customer.php Normal file
View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace BTCPayServer\Result;
class Customer extends AbstractResult
{
public function getStoreId(): string
{
return $this->getData()['storeId'];
}
public function getId(): string
{
return $this->getData()['id'];
}
public function getExternalId(): ?string
{
return $this->getData()['externalId'] ?? null;
}
public function getIdentities(): ?array
{
return $this->getData()['identities'] ?? null;
}
public function getMetadata(): ?array
{
return $this->getData()['metadata'] ?? null;
}
}

18
src/Result/Feature.php Normal file
View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace BTCPayServer\Result;
class Feature extends AbstractResult
{
public function getId(): string
{
return $this->getData()['id'];
}
public function getDescription(): string
{
return $this->getData()['description'];
}
}

66
src/Result/Offering.php Normal file
View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace BTCPayServer\Result;
class Offering extends AbstractResult
{
public function getId(): string
{
return $this->getData()['id'];
}
public function getStoreId(): string
{
return $this->getData()['storeId'];
}
public function getAppId(): ?string
{
return $this->getData()['appId'] ?? null;
}
public function getAppName(): ?string
{
return $this->getData()['appName'] ?? null;
}
public function getSuccessRedirectUrl(): ?string
{
return $this->getData()['successRedirectUrl'] ?? null;
}
public function getMetadata(): ?array
{
return $this->getData()['metadata'] ?? null;
}
/**
* @return OfferingPlan[]
*/
public function getPlans(): array
{
$plans = [];
if (isset($this->getData()['plans']) && is_array($this->getData()['plans'])) {
foreach ($this->getData()['plans'] as $plan) {
$plans[] = new OfferingPlan($plan);
}
}
return $plans;
}
/**
* @return Feature[]
*/
public function getFeatures(): array
{
$features = [];
if (isset($this->getData()['features']) && is_array($this->getData()['features'])) {
foreach ($this->getData()['features'] as $feature) {
$features[] = new Feature($feature);
}
}
return $features;
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace BTCPayServer\Result;
class OfferingList extends AbstractListResult
{
/**
* @return Offering[]
*/
public function all(): array
{
$result = [];
foreach ($this->getData() as $item) {
$result[] = new Offering($item);
}
return $result;
}
}

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace BTCPayServer\Result;
class OfferingPlan extends AbstractResult
{
public function getId(): string
{
return $this->getData()['id'];
}
public function getName(): string
{
return $this->getData()['name'];
}
public function getStatus(): string
{
return $this->getData()['status'];
}
public function getPrice(): string
{
return $this->getData()['price'];
}
public function getCurrency(): string
{
return $this->getData()['currency'];
}
public function getRecurringType(): string
{
return $this->getData()['recurringType'];
}
public function getGracePeriodDays(): int
{
return $this->getData()['gracePeriodDays'];
}
public function getTrialDays(): int
{
return $this->getData()['trialDays'];
}
public function getDescription(): string
{
return $this->getData()['description'];
}
public function getMemberCount(): int
{
return $this->getData()['memberCount'];
}
public function isOptimisticActivation(): bool
{
return $this->getData()['optimisticActivation'];
}
public function isRenewable(): bool
{
return $this->getData()['renewable'];
}
/**
* @return string[]
*/
public function getFeatures(): array
{
return $this->getData()['features'] ?? [];
}
public function getMetadata(): ?array
{
return $this->getData()['metadata'] ?? null;
}
}

113
src/Result/PlanCheckout.php Normal file
View File

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace BTCPayServer\Result;
class PlanCheckout extends AbstractResult
{
public function getId(): string
{
return $this->getData()['id'];
}
public function getSubscriber(): ?Subscriber
{
return isset($this->getData()['subscriber']) ? new Subscriber($this->getData()['subscriber']) : null;
}
public function getPlan(): OfferingPlan
{
return new OfferingPlan($this->getData()['plan']);
}
public function getBaseUrl(): string
{
return $this->getData()['baseUrl'];
}
public function getInvoiceId(): ?string
{
return $this->getData()['invoiceId'] ?? null;
}
public function getSuccessRedirectUrl(): ?string
{
return $this->getData()['successRedirectUrl'] ?? null;
}
public function getExpiration(): int
{
return $this->getData()['expiration'];
}
public function getRedirectUrl(): string
{
return $this->getData()['redirectUrl'];
}
public function getInvoiceMetadata(): ?array
{
return $this->getData()['invoiceMetadata'] ?? null;
}
public function getMetadata(): ?array
{
return $this->getData()['metadata'] ?? null;
}
public function isNewSubscriber(): bool
{
return $this->getData()['newSubscriber'];
}
public function isTrial(): bool
{
return $this->getData()['isTrial'];
}
public function getCreated(): int
{
return $this->getData()['created'];
}
public function isPlanStarted(): bool
{
return $this->getData()['planStarted'];
}
public function getNewSubscriberMetadata(): ?array
{
return $this->getData()['newSubscriberMetadata'] ?? null;
}
public function getRefundAmount(): ?string
{
return $this->getData()['refundAmount'] ?? null;
}
public function getCreditedByInvoice(): ?string
{
return $this->getData()['creditedByInvoice'] ?? null;
}
public function getOnPayBehavior(): ?string
{
return $this->getData()['onPayBehavior'] ?? null;
}
public function isExpired(): bool
{
return $this->getData()['isExpired'];
}
public function getUrl(): string
{
return $this->getData()['url'];
}
public function getCreditPurchase(): ?string
{
return $this->getData()['creditPurchase'] ?? null;
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace BTCPayServer\Result;
class PortalSession extends AbstractResult
{
public function getId(): string
{
return $this->getData()['id'];
}
public function getBaseUrl(): string
{
return $this->getData()['baseUrl'];
}
public function getSubscriber(): Subscriber
{
return new Subscriber($this->getData()['subscriber']);
}
public function getExpiration(): ?int
{
return $this->getData()['expiration'] ?? null;
}
public function isExpired(): bool
{
return $this->getData()['isExpired'];
}
public function getUrl(): string
{
return $this->getData()['url'];
}
}

93
src/Result/Subscriber.php Normal file
View File

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace BTCPayServer\Result;
class Subscriber extends AbstractResult
{
public function getCreated(): int
{
return $this->getData()['created'];
}
public function getCustomer(): Customer
{
return new Customer($this->getData()['customer']);
}
public function getOffering(): Offering
{
return new Offering($this->getData()['offering']);
}
public function getPlan(): OfferingPlan
{
return new OfferingPlan($this->getData()['plan']);
}
public function getPeriodEnd(): ?int
{
return $this->getData()['periodEnd'] ?? null;
}
public function getTrialEnd(): ?int
{
return $this->getData()['trialEnd'] ?? null;
}
public function getGracePeriodEnd(): ?int
{
return $this->getData()['gracePeriodEnd'] ?? null;
}
public function isActive(): bool
{
return $this->getData()['isActive'];
}
public function isSuspended(): bool
{
return $this->getData()['isSuspended'];
}
public function getSuspensionReason(): ?string
{
return $this->getData()['suspensionReason'] ?? null;
}
public function isAutoRenew(): bool
{
return $this->getData()['autoRenew'];
}
public function getMetadata(): ?array
{
return $this->getData()['metadata'] ?? null;
}
public function getProcessingInvoiceId(): ?string
{
return $this->getData()['processingInvoiceId'] ?? null;
}
public function getNextPlan(): ?OfferingPlan
{
return isset($this->getData()['nextPlan']) ? new OfferingPlan($this->getData()['nextPlan']) : null;
}
public function getScheduledPlan(): ?OfferingPlan
{
return isset($this->getData()['scheduledPlan']) ? new OfferingPlan($this->getData()['scheduledPlan']) : null;
}
public function getScheduledPlanActivatesAt(): ?int
{
return $this->getData()['scheduledPlanActivatesAt'] ?? null;
}
public function getPhase(): string
{
return $this->getData()['phase'];
}
}