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, }; } }