php-commerce/Services/PaymentGateway/BTCPayGateway.php

489 lines
16 KiB
PHP
Raw Normal View History

2026-01-27 00:24:22 +00:00
<?php
namespace Core\Commerce\Services\PaymentGateway;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Core\Commerce\Models\Order;
use Core\Commerce\Models\Payment;
use Core\Commerce\Models\PaymentMethod;
use Core\Commerce\Models\Subscription;
use Core\Mod\Tenant\Models\Workspace;
/**
* BTCPay Server payment gateway implementation.
*
* This is the primary payment gateway for Host UK, supporting
* Bitcoin, Litecoin, and Monero payments.
*/
class BTCPayGateway implements PaymentGatewayContract
{
protected string $baseUrl;
protected string $storeId;
protected string $apiKey;
protected string $webhookSecret;
public function __construct()
{
$this->baseUrl = rtrim(config('commerce.gateways.btcpay.url') ?? '', '/');
$this->storeId = config('commerce.gateways.btcpay.store_id') ?? '';
$this->apiKey = config('commerce.gateways.btcpay.api_key') ?? '';
$this->webhookSecret = config('commerce.gateways.btcpay.webhook_secret') ?? '';
}
public function getIdentifier(): string
{
return 'btcpay';
}
public function isEnabled(): bool
{
return config('commerce.gateways.btcpay.enabled', false)
&& $this->storeId
&& $this->apiKey;
}
// Customer Management
public function createCustomer(Workspace $workspace): string
{
// BTCPay doesn't have a customer concept like Stripe
// We generate a unique identifier for the workspace
$customerId = 'btc_cus_'.Str::ulid();
$workspace->update(['btcpay_customer_id' => $customerId]);
return $customerId;
}
public function updateCustomer(Workspace $workspace): void
{
// BTCPay doesn't store customer details
// No-op but could sync to external systems
}
// Checkout
public function createCheckoutSession(Order $order, string $successUrl, string $cancelUrl): array
{
try {
$response = $this->request('POST', "/api/v1/stores/{$this->storeId}/invoices", [
'amount' => (string) $order->total,
'currency' => $order->currency,
'metadata' => [
'order_id' => $order->id,
'order_number' => $order->order_number,
'workspace_id' => $order->workspace_id,
],
'checkout' => [
'redirectURL' => $successUrl,
'redirectAutomatically' => true,
'requiresRefundEmail' => true,
],
'receipt' => [
'enabled' => true,
'showQr' => true,
],
]);
if (empty($response['id'])) {
Log::error('BTCPay checkout: Invalid response - missing invoice ID', [
'order_id' => $order->id,
]);
throw new \RuntimeException('Invalid response from payment service.');
}
$invoiceId = $response['id'];
$checkoutUrl = "{$this->baseUrl}/i/{$invoiceId}";
// Store the BTCPay invoice ID in the order
$order->update([
'gateway_session_id' => $invoiceId,
]);
return [
'session_id' => $invoiceId,
'checkout_url' => $checkoutUrl,
];
} catch (\RuntimeException $e) {
// Re-throw RuntimeExceptions (already logged/handled)
throw $e;
} catch (\Exception $e) {
Log::error('BTCPay checkout failed', [
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
throw new \RuntimeException('Unable to create checkout session. Please try again or contact support.', 0, $e);
}
}
public function getCheckoutSession(string $sessionId): array
{
$response = $this->request('GET', "/api/v1/stores/{$this->storeId}/invoices/{$sessionId}");
return [
'id' => $response['id'],
'status' => $this->mapInvoiceStatus($response['status']),
'amount' => $response['amount'],
'currency' => $response['currency'],
'paid_at' => $response['status'] === 'Settled' ? now() : null,
'metadata' => $response['metadata'] ?? [],
'raw' => $response,
];
}
// Payments
public function charge(Workspace $workspace, int $amountCents, string $currency, array $metadata = []): Payment
{
// BTCPay requires creating an invoice - customer pays by visiting checkout
// This creates a "pending" invoice that awaits payment
$response = $this->request('POST', "/api/v1/stores/{$this->storeId}/invoices", [
'amount' => (string) ($amountCents / 100),
'currency' => $currency,
'metadata' => array_merge($metadata, [
'workspace_id' => $workspace->id,
]),
]);
return Payment::create([
'workspace_id' => $workspace->id,
'gateway' => 'btcpay',
'gateway_payment_id' => $response['id'],
'amount' => $amountCents / 100,
'currency' => $currency,
'status' => 'pending',
'gateway_response' => $response,
]);
}
public function chargePaymentMethod(PaymentMethod $paymentMethod, int $amountCents, string $currency, array $metadata = []): Payment
{
// BTCPay doesn't support automatic recurring charges like traditional payment processors.
// Each payment requires customer action (visiting checkout URL and sending crypto).
//
// For subscription renewals, we create a pending invoice that requires manual payment.
// The dunning system will notify the customer, but auto-retry won't work for crypto.
//
// This returns a 'pending' payment - the webhook will update it when payment arrives.
return $this->charge($paymentMethod->workspace, $amountCents, $currency, $metadata);
}
// Subscriptions - BTCPay doesn't natively support subscriptions
// We implement a manual recurring billing approach
public function createSubscription(Workspace $workspace, string $priceId, array $options = []): Subscription
{
// BTCPay doesn't have native subscription support
// We create a local subscription record and manage billing manually
$subscription = Subscription::create([
'workspace_id' => $workspace->id,
'gateway' => 'btcpay',
'gateway_subscription_id' => 'btcsub_'.Str::ulid(),
'gateway_customer_id' => $workspace->btcpay_customer_id,
'gateway_price_id' => $priceId,
'status' => 'active',
'current_period_start' => now(),
'current_period_end' => now()->addMonth(), // Default to monthly
'trial_ends_at' => isset($options['trial_days']) && $options['trial_days'] > 0
? now()->addDays($options['trial_days'])
: null,
]);
return $subscription;
}
public function updateSubscription(Subscription $subscription, array $options): Subscription
{
// Update local subscription record
$updates = [];
if (isset($options['price_id'])) {
$updates['gateway_price_id'] = $options['price_id'];
}
if (! empty($updates)) {
$subscription->update($updates);
}
return $subscription->fresh();
}
public function cancelSubscription(Subscription $subscription, bool $immediately = false): void
{
$subscription->cancel($immediately);
}
public function resumeSubscription(Subscription $subscription): void
{
$subscription->resume();
}
public function pauseSubscription(Subscription $subscription): void
{
$subscription->pause();
}
// Payment Methods - BTCPay doesn't support saved payment methods
public function createSetupSession(Workspace $workspace, string $returnUrl): array
{
// BTCPay doesn't support saving payment methods
// Return a no-op response
return [
'session_id' => null,
'setup_url' => $returnUrl,
];
}
public function attachPaymentMethod(Workspace $workspace, string $gatewayPaymentMethodId): PaymentMethod
{
// Create a placeholder payment method for crypto
return PaymentMethod::create([
'workspace_id' => $workspace->id,
'gateway' => 'btcpay',
'gateway_payment_method_id' => $gatewayPaymentMethodId,
'type' => 'crypto',
'is_default' => true,
]);
}
public function detachPaymentMethod(PaymentMethod $paymentMethod): void
{
$paymentMethod->delete();
}
public function setDefaultPaymentMethod(PaymentMethod $paymentMethod): void
{
// Unset other defaults
PaymentMethod::where('workspace_id', $paymentMethod->workspace_id)
->where('id', '!=', $paymentMethod->id)
->update(['is_default' => false]);
$paymentMethod->update(['is_default' => true]);
}
// Refunds
public function refund(Payment $payment, float $amount, ?string $reason = null): array
{
// BTCPay refunds require manual processing via the API
try {
$response = $this->request('POST', "/api/v1/stores/{$this->storeId}/invoices/{$payment->gateway_payment_id}/refund", [
'refundVariant' => 'Custom',
'customAmount' => $amount,
'customCurrency' => $payment->currency,
'description' => $reason ?? 'Refund requested',
]);
return [
'success' => true,
'refund_id' => $response['id'] ?? null,
'gateway_response' => $response,
];
} catch (\Exception $e) {
Log::warning('BTCPay refund creation failed', [
'payment_id' => $payment->id,
'error' => $e->getMessage(),
]);
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
// Invoices
public function getInvoice(string $gatewayInvoiceId): array
{
return $this->request('GET', "/api/v1/stores/{$this->storeId}/invoices/{$gatewayInvoiceId}");
}
public function getInvoicePdfUrl(string $gatewayInvoiceId): ?string
{
// BTCPay doesn't provide invoice PDFs - we generate our own
return null;
}
// Webhooks
public function verifyWebhookSignature(string $payload, string $signature): bool
{
if (! $this->webhookSecret) {
Log::warning('BTCPay webhook: No webhook secret configured');
return false;
}
if (empty($signature)) {
Log::warning('BTCPay webhook: Empty signature provided');
return false;
}
// BTCPay may send signature with 'sha256=' prefix
$providedSignature = $signature;
if (str_starts_with($signature, 'sha256=')) {
$providedSignature = substr($signature, 7);
}
$expectedSignature = hash_hmac('sha256', $payload, $this->webhookSecret);
if (! hash_equals($expectedSignature, $providedSignature)) {
Log::warning('BTCPay webhook: Signature mismatch');
return false;
}
return true;
}
public function parseWebhookEvent(string $payload): array
{
$data = json_decode($payload, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::warning('BTCPay webhook: Invalid JSON payload', [
'error' => json_last_error_msg(),
]);
return [
'type' => 'unknown',
'id' => null,
'status' => 'unknown',
'metadata' => [],
'raw' => [],
];
}
$type = $data['type'] ?? 'unknown';
$invoiceId = $data['invoiceId'] ?? $data['id'] ?? null;
return [
'type' => $this->mapWebhookEventType($type),
'id' => $invoiceId,
'status' => $this->mapInvoiceStatus($data['status'] ?? $data['afterExpiration'] ?? 'unknown'),
'metadata' => $data['metadata'] ?? [],
'raw' => $data,
];
}
// Tax
public function createTaxRate(string $name, float $percentage, string $country, bool $inclusive = false): string
{
// BTCPay doesn't have tax rate management - handled locally
return 'local_'.Str::slug($name);
}
// Portal
public function getPortalUrl(Workspace $workspace, string $returnUrl): ?string
{
// BTCPay doesn't have a customer portal
return null;
}
// Helper Methods
protected function request(string $method, string $endpoint, array $data = []): array
{
if (! $this->baseUrl || ! $this->apiKey) {
throw new \RuntimeException('BTCPay is not configured. Please check BTCPAY_URL and BTCPAY_API_KEY.');
}
$url = $this->baseUrl.$endpoint;
$http = Http::withHeaders([
'Authorization' => "token {$this->apiKey}",
'Content-Type' => 'application/json',
])->timeout(30);
$response = match (strtoupper($method)) {
'GET' => $http->get($url, $data),
'POST' => $http->post($url, $data),
'PUT' => $http->put($url, $data),
'DELETE' => $http->delete($url, $data),
default => throw new \InvalidArgumentException("Unsupported HTTP method: {$method}"),
};
if ($response->failed()) {
// Sanitise error logging - don't expose full response body which may contain sensitive data
$errorMessage = $this->sanitiseErrorMessage($response);
Log::error('BTCPay API request failed', [
'method' => $method,
'endpoint' => $endpoint,
'status' => $response->status(),
'error' => $errorMessage,
]);
throw new \RuntimeException("BTCPay API request failed ({$response->status()}): {$errorMessage}");
}
return $response->json() ?? [];
}
/**
* Extract a safe error message from a failed response.
*/
protected function sanitiseErrorMessage(\Illuminate\Http\Client\Response $response): string
{
$json = $response->json();
// BTCPay returns structured errors
if (isset($json['message'])) {
return $json['message'];
}
if (isset($json['error'])) {
return is_string($json['error']) ? $json['error'] : 'Unknown error';
}
// Map common HTTP status codes
return match ($response->status()) {
400 => 'Bad request',
401 => 'Unauthorised - check API key',
403 => 'Forbidden - insufficient permissions',
404 => 'Resource not found',
422 => 'Validation failed',
429 => 'Rate limited',
500, 502, 503, 504 => 'Server error',
default => 'Request failed',
};
}
protected function mapInvoiceStatus(string $status): string
{
return match (strtolower($status)) {
'new' => 'pending',
'processing' => 'processing',
'expired' => 'expired',
'invalid' => 'failed',
'settled' => 'succeeded',
'complete', 'confirmed' => 'succeeded',
default => 'pending',
};
}
protected function mapWebhookEventType(string $type): string
{
return match ($type) {
'InvoiceCreated' => 'invoice.created',
'InvoiceReceivedPayment' => 'invoice.payment_received',
'InvoiceProcessing' => 'invoice.processing',
'InvoiceExpired' => 'invoice.expired',
'InvoiceSettled' => 'invoice.paid',
'InvoiceInvalid' => 'invoice.failed',
'InvoicePaymentSettled' => 'payment.settled',
default => $type,
};
}
}