Files
zemailnator/app/Services/Payments/Providers/CryptoProvider.php
idevakk 27ac13948c feat: implement comprehensive multi-provider payment processing system
- Add unified payment provider architecture with contract-based design
  - Implement 6 payment providers: Stripe, Lemon Squeezy, Polar, Oxapay, Crypto, Activation Keys
  - Create subscription management with lifecycle handling (create, cancel, pause, resume, update)
  - Add coupon system with usage tracking and trial extensions
  - Build Filament admin resources for payment providers, subscriptions, coupons, and trials
  - Implement payment orchestration service with provider registry and configuration management
  - Add comprehensive payment logging and webhook handling for all providers
  - Create customer analytics dashboard with revenue, churn, and lifetime value metrics
  - Add subscription migration service for provider switching
  - Include extensive test coverage for all payment functionality
2025-11-19 09:37:00 -08:00

640 lines
20 KiB
PHP

<?php
namespace App\Services\Payments\Providers;
use App\Contracts\Payments\PaymentProviderContract;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class CryptoProvider implements PaymentProviderContract
{
protected array $config;
protected array $supportedCryptos = [
'BTC' => [
'name' => 'Bitcoin',
'network' => 'mainnet',
'confirmations_required' => 3,
'block_time_minutes' => 10,
],
'ETH' => [
'name' => 'Ethereum',
'network' => 'mainnet',
'confirmations_required' => 12,
'block_time_minutes' => 12,
],
'USDT' => [
'name' => 'Tether',
'network' => 'ethereum',
'confirmations_required' => 12,
'block_time_minutes' => 12,
],
'USDC' => [
'name' => 'USD Coin',
'network' => 'ethereum',
'confirmations_required' => 12,
'block_time_minutes' => 12,
],
'LTC' => [
'name' => 'Litecoin',
'network' => 'mainnet',
'confirmations_required' => 6,
'block_time_minutes' => 2.5,
],
];
public function __construct(array $config = [])
{
$defaultConfig = [
'webhook_secret' => null,
'success_url' => null,
'cancel_url' => null,
'confirmation_timeout_minutes' => 30,
'exchange_rate_provider' => 'coingecko', // or 'binance'
];
// Try to get config values if Laravel is available
try {
if (function_exists('config')) {
$defaultConfig['webhook_secret'] = config('payments.crypto.webhook_secret');
}
if (function_exists('route')) {
$defaultConfig['success_url'] = route('payment.success');
$defaultConfig['cancel_url'] = route('payment.cancel');
}
} catch (\Exception $e) {
// Laravel not available, keep defaults
}
$this->config = array_merge($defaultConfig, $config);
}
public function getName(): string
{
return 'crypto';
}
public function isActive(): bool
{
return ! empty($this->config['webhook_secret']);
}
public function createSubscription(User $user, Plan $plan, array $options = []): array
{
try {
$crypto = $options['crypto'] ?? 'BTC';
$usdAmount = $plan->price;
// Get current exchange rate
$cryptoAmount = $this->convertUsdToCrypto($usdAmount, $crypto);
// Generate payment address
$paymentAddress = $this->generatePaymentAddress($crypto);
// Create payment record
$paymentId = $this->createPaymentRecord([
'user_id' => $user->id,
'plan_id' => $plan->id,
'crypto' => $crypto,
'usd_amount' => $usdAmount,
'crypto_amount' => $cryptoAmount,
'address' => $paymentAddress,
'status' => 'pending',
'expires_at' => now()->addMinutes($this->config['confirmation_timeout_minutes']),
]);
return [
'provider_subscription_id' => $paymentId,
'status' => 'pending_payment',
'payment_address' => $paymentAddress,
'crypto' => $crypto,
'crypto_amount' => $cryptoAmount,
'usd_amount' => $usdAmount,
'expires_at' => now()->addMinutes($this->config['confirmation_timeout_minutes'])->toISOString(),
'type' => 'crypto_payment',
];
} catch (\Exception $e) {
Log::error('Crypto subscription creation failed', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function cancelSubscription(Subscription $subscription, string $reason = ''): bool
{
try {
// For crypto, we just mark as cancelled since there's no external subscription
$paymentId = $subscription->provider_subscription_id;
$this->updatePaymentRecord($paymentId, [
'status' => 'cancelled',
'cancelled_at' => now(),
'cancellation_reason' => $reason,
]);
return true;
} catch (\Exception $e) {
Log::error('Crypto subscription cancellation failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
{
try {
// Cancel old payment and create new one for upgraded plan
$this->cancelSubscription($subscription, 'Plan upgrade');
$user = $subscription->user;
return $this->createSubscription($user, $newPlan);
} catch (\Exception $e) {
Log::error('Crypto subscription update failed', [
'subscription_id' => $subscription->id,
'new_plan_id' => $newPlan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function pauseSubscription(Subscription $subscription): bool
{
// Crypto subscriptions don't support pausing in the traditional sense
// We could implement a temporary suspension logic here if needed
return false;
}
public function resumeSubscription(Subscription $subscription): bool
{
// Crypto subscriptions don't support pausing
return false;
}
public function getSubscriptionDetails(string $providerSubscriptionId): array
{
try {
$payment = $this->getPaymentRecord($providerSubscriptionId);
return [
'id' => $payment['id'],
'status' => $payment['status'],
'crypto' => $payment['crypto'],
'usd_amount' => $payment['usd_amount'],
'crypto_amount' => $payment['crypto_amount'],
'address' => $payment['address'],
'confirmations' => $payment['confirmations'],
'created_at' => $payment['created_at'],
'expires_at' => $payment['expires_at'],
'confirmed_at' => $payment['confirmed_at'],
];
} catch (\Exception $e) {
Log::error('Crypto subscription details retrieval failed', [
'subscription_id' => $providerSubscriptionId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array
{
return $this->createSubscription($user, $plan, $options);
}
public function createCustomerPortalSession(User $user): array
{
// Crypto doesn't have customer portals
return [
'portal_url' => route('dashboard'),
'message' => 'Crypto payments are managed through the dashboard',
];
}
public function processWebhook(Request $request): array
{
try {
$payload = $request->getContent();
$webhookData = json_decode($payload, true);
if (! $this->validateWebhook($request)) {
throw new \Exception('Invalid webhook signature');
}
$eventType = $webhookData['type'] ?? 'unknown';
$result = [
'event_type' => $eventType,
'processed' => false,
'data' => [],
];
switch ($eventType) {
case 'payment_received':
$result = $this->handlePaymentReceived($webhookData);
break;
case 'payment_confirmed':
$result = $this->handlePaymentConfirmed($webhookData);
break;
case 'payment_expired':
$result = $this->handlePaymentExpired($webhookData);
break;
default:
Log::info('Unhandled crypto webhook event', ['event_type' => $eventType]);
}
return $result;
} catch (\Exception $e) {
Log::error('Crypto webhook processing failed', [
'error' => $e->getMessage(),
'payload' => $request->getContent(),
]);
throw $e;
}
}
public function validateWebhook(Request $request): bool
{
try {
$signature = $request->header('X-Signature');
$payload = $request->getContent();
if (! $signature || ! $this->config['webhook_secret']) {
return false;
}
$expectedSignature = hash_hmac('sha256', $payload, $this->config['webhook_secret']);
return hash_equals($signature, $expectedSignature);
} catch (\Exception $e) {
Log::warning('Crypto webhook validation failed', [
'error' => $e->getMessage(),
]);
return false;
}
}
public function getConfiguration(): array
{
return $this->config;
}
public function syncSubscriptionStatus(Subscription $subscription): array
{
return $this->getSubscriptionDetails($subscription->provider_subscription_id);
}
public function getPaymentMethodDetails(string $paymentMethodId): array
{
try {
$payment = $this->getPaymentRecord($paymentMethodId);
return [
'id' => $payment['id'],
'type' => 'crypto_address',
'crypto' => $payment['crypto'],
'address' => $payment['address'],
'network' => $this->supportedCryptos[$payment['crypto']]['network'] ?? 'unknown',
'created_at' => $payment['created_at'],
];
} catch (\Exception $e) {
Log::error('Crypto payment method details retrieval failed', [
'payment_method_id' => $paymentMethodId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function processRefund(string $paymentId, float $amount, string $reason = ''): array
{
try {
// Crypto payments are typically not refundable
// We could implement a manual refund process if needed
throw new \Exception('Crypto payments are not refundable');
} catch (\Exception $e) {
Log::error('Crypto refund processing failed', [
'payment_id' => $paymentId,
'amount' => $amount,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function getTransactionHistory(User $user, array $filters = []): array
{
try {
$payments = $this->getUserPayments($user->id, $filters);
$transactions = [];
foreach ($payments as $payment) {
$transactions[] = [
'id' => $payment['id'],
'crypto' => $payment['crypto'],
'amount' => $payment['usd_amount'],
'crypto_amount' => $payment['crypto_amount'],
'status' => $payment['status'],
'address' => $payment['address'],
'confirmations' => $payment['confirmations'],
'created_at' => $payment['created_at'],
'confirmed_at' => $payment['confirmed_at'],
];
}
return $transactions;
} catch (\Exception $e) {
Log::error('Crypto transaction history retrieval failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function calculateFees(float $amount): array
{
// Crypto fees: 1% network fee + 0.5% service fee
$networkFee = $amount * 0.01;
$serviceFee = $amount * 0.005;
$totalFee = $networkFee + $serviceFee;
return [
'fixed_fee' => 0,
'percentage_fee' => $totalFee,
'total_fee' => $totalFee,
'net_amount' => $amount - $totalFee,
];
}
public function getSupportedCurrencies(): array
{
return ['USD']; // We accept USD but process in crypto
}
public function supportsRecurring(): bool
{
return true; // Through manual renewal
}
public function supportsOneTime(): bool
{
return true;
}
// Helper methods
public function convertUsdToCrypto(float $usdAmount, string $crypto): float
{
$cacheKey = "crypto_rate_{$crypto}_usd";
return Cache::remember($cacheKey, now()->addMinutes(5), function () use ($usdAmount, $crypto) {
$rate = $this->getExchangeRate($crypto, 'USD');
return $usdAmount / $rate;
});
}
protected function getExchangeRate(string $fromCrypto, string $toCurrency): float
{
// This would integrate with CoinGecko, Binance, or other exchange rate APIs
// For now, return mock rates
$mockRates = [
'BTC' => 45000.00, // 1 BTC = $45,000
'ETH' => 3000.00, // 1 ETH = $3,000
'USDT' => 1.00, // 1 USDT = $1.00
'USDC' => 1.00, // 1 USDC = $1.00
'LTC' => 150.00, // 1 LTC = $150
];
return $mockRates[$fromCrypto] ?? 1.0;
}
protected function generatePaymentAddress(string $crypto): string
{
// In a real implementation, this would integrate with a crypto payment processor
// For now, generate a mock address
$prefix = [
'BTC' => 'bc1q',
'ETH' => '0x',
'USDT' => '0x',
'USDC' => '0x',
'LTC' => 'ltc1',
];
$randomPart = bin2hex(random_bytes(32));
return ($prefix[$crypto] ?? '0x').substr($randomPart, 0, 40);
}
protected function createPaymentRecord(array $data): string
{
// In a real implementation, this would save to a database
// For now, generate a mock ID and cache it
$paymentId = 'crypto_'.uniqid(more_entropy: true);
Cache::put("crypto_payment_{$paymentId}", array_merge($data, [
'id' => $paymentId,
'created_at' => now()->toISOString(),
'confirmations' => 0,
]), now()->addHours(24));
return $paymentId;
}
protected function getPaymentRecord(string $paymentId): array
{
return Cache::get("crypto_payment_{$paymentId}", []);
}
protected function updatePaymentRecord(string $paymentId, array $updates): void
{
$payment = Cache::get("crypto_payment_{$paymentId}", []);
if ($payment) {
$updatedPayment = array_merge($payment, $updates);
Cache::put("crypto_payment_{$paymentId}", $updatedPayment, now()->addHours(24));
}
}
protected function getUserPayments(int $userId, array $filters = []): array
{
// In a real implementation, this would query the database
// For now, return empty array
return [];
}
protected function confirmPayment(string $paymentId, int $confirmations, string $transactionHash): void
{
$this->updatePaymentRecord($paymentId, [
'status' => 'confirmed',
'confirmations' => $confirmations,
'transaction_hash' => $transactionHash,
'confirmed_at' => now()->toISOString(),
]);
}
// Webhook handlers
protected function handlePaymentReceived(array $webhookData): array
{
$paymentId = $webhookData['payment_id'];
$confirmations = $webhookData['confirmations'] ?? 0;
$transactionHash = $webhookData['transaction_hash'] ?? '';
$this->confirmPayment($paymentId, $confirmations, $transactionHash);
return [
'event_type' => 'payment_received',
'processed' => true,
'data' => [
'payment_id' => $paymentId,
'confirmations' => $confirmations,
'transaction_hash' => $transactionHash,
],
];
}
protected function handlePaymentConfirmed(array $webhookData): array
{
$paymentId = $webhookData['payment_id'];
// Mark as fully confirmed
$this->updatePaymentRecord($paymentId, [
'status' => 'completed',
]);
return [
'event_type' => 'payment_confirmed',
'processed' => true,
'data' => [
'payment_id' => $paymentId,
],
];
}
protected function handlePaymentExpired(array $webhookData): array
{
$paymentId = $webhookData['payment_id'];
$this->updatePaymentRecord($paymentId, [
'status' => 'expired',
]);
return [
'event_type' => 'payment_expired',
'processed' => true,
'data' => [
'payment_id' => $paymentId,
],
];
}
// Additional interface methods
public function getSubscriptionMetadata(Subscription $subscription): array
{
$payment = $this->getPaymentRecord($subscription->provider_subscription_id);
return $payment['metadata'] ?? [];
}
public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool
{
$paymentId = $subscription->provider_subscription_id;
$payment = $this->getPaymentRecord($paymentId);
if ($payment) {
$this->updatePaymentRecord($paymentId, ['metadata' => $metadata]);
return true;
}
return false;
}
public function startTrial(Subscription $subscription, int $trialDays): bool
{
// Crypto subscriptions don't have trials in the traditional sense
return false;
}
public function applyCoupon(Subscription $subscription, string $couponCode): array
{
// Crypto doesn't support coupons natively
throw new \Exception('Coupons not supported for crypto payments');
}
public function removeCoupon(Subscription $subscription): bool
{
return false; // No coupons to remove
}
public function getUpcomingInvoice(Subscription $subscription): array
{
// Crypto subscriptions don't have invoices
return [
'amount_due' => 0,
'currency' => 'USD',
'next_payment_date' => null,
];
}
public function retryFailedPayment(Subscription $subscription): array
{
// Crypto payments can't be retried automatically
// User would need to make a new payment
return $this->syncSubscriptionStatus($subscription);
}
public function canModifySubscription(Subscription $subscription): bool
{
try {
$details = $this->getSubscriptionDetails($subscription->provider_subscription_id);
return in_array($details['status'], ['pending_payment', 'confirmed', 'completed']);
} catch (\Exception $e) {
return false;
}
}
public function getCancellationTerms(Subscription $subscription): array
{
return [
'immediate_cancellation' => true,
'refund_policy' => 'non_refundable',
'cancellation_effective' => 'immediately',
'billing_cycle_proration' => false,
];
}
public function exportSubscriptionData(Subscription $subscription): array
{
return [
'provider' => 'crypto',
'provider_subscription_id' => $subscription->provider_subscription_id,
'data' => $subscription->provider_data,
];
}
public function importSubscriptionData(User $user, array $subscriptionData): array
{
throw new \Exception('Import to crypto payments not implemented');
}
}