php-commerce/Services/PaymentGateway/BTCPayGateway.php
Snider a774f4e285 refactor: migrate namespace from Core\Commerce to Core\Mod\Commerce
Align commerce module with the monorepo module structure by updating
all namespaces to use the Core\Mod\Commerce convention. This change
supports the recent monorepo separation and ensures consistency with
other modules.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 16:23:12 +00:00

488 lines
16 KiB
PHP

<?php
namespace Core\Mod\Commerce\Services\PaymentGateway;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\Payment;
use Core\Mod\Commerce\Models\PaymentMethod;
use Core\Mod\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,
};
}
}