Files
zemailnator/app/Services/Payments/Providers/StripeProvider.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

839 lines
26 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 Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Subscription as CashierSubscription;
use Stripe\Checkout\Session;
use Stripe\Customer;
use Stripe\Price;
use Stripe\Product;
use Stripe\Stripe;
class StripeProvider implements PaymentProviderContract
{
protected array $config;
protected ?string $apiKey;
public function __construct(array $config = [])
{
$this->config = array_merge([
'api_key' => config('services.stripe.secret'),
'webhook_secret' => config('services.stripe.webhook_secret'),
'currency' => config('cashier.currency', 'USD'),
'success_url' => route('payment.success'),
'cancel_url' => route('payment.cancel'),
], $config);
$this->apiKey = $this->config['api_key'] ?? null;
if ($this->apiKey) {
Stripe::setApiKey($this->apiKey);
}
}
public function getName(): string
{
return 'stripe';
}
public function isActive(): bool
{
return ! empty($this->apiKey) && $this->apiKey !== 'sk_test_placeholder';
}
public function createSubscription(User $user, Plan $plan, array $options = []): array
{
try {
// Create or retrieve Stripe customer
$customer = $this->getOrCreateCustomer($user);
// Create or retrieve Stripe product and price
$priceId = $this->getOrCreatePrice($plan);
// Create subscription
$subscriptionBuilder = $user->newSubscription('default', $priceId);
if (! empty($options['trial_days'])) {
$subscriptionBuilder->trialDays($options['trial_days']);
}
if (! empty($options['coupon'])) {
$subscriptionBuilder->withCoupon($options['coupon']);
}
if (! empty($options['payment_method'])) {
$subscriptionBuilder->create($options['payment_method']);
} else {
// Create checkout session for payment method collection
$sessionId = $this->createCheckoutSession($user, $plan, $options);
return [
'requires_action' => true,
'checkout_session_id' => $sessionId,
'type' => 'checkout_session',
];
}
$stripeSubscription = $subscriptionBuilder->create();
return [
'provider_subscription_id' => $stripeSubscription->stripe_id,
'status' => $stripeSubscription->stripe_status,
'current_period_start' => $stripeSubscription->created_at,
'current_period_end' => $stripeSubscription->ends_at,
'trial_ends_at' => $stripeSubscription->trial_ends_at,
'customer_id' => $stripeSubscription->stripe_id,
];
} catch (Exception $e) {
Log::error('Stripe 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 {
$cashierSubscription = CashierSubscription::find($subscription->stripe_id);
if (! $cashierSubscription) {
throw new Exception('Cashier subscription not found');
}
// Cancel immediately or at period end
$cashierSubscription->cancel();
return true;
} catch (Exception $e) {
Log::error('Stripe subscription cancellation failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function updateSubscription(Subscription $subscription, Plan $newPlan): array
{
try {
$cashierSubscription = CashierSubscription::find($subscription->stripe_id);
if (! $cashierSubscription) {
throw new Exception('Cashier subscription not found');
}
$newPriceId = $this->getOrCreatePrice($newPlan);
// Swap to new plan
$cashierSubscription->swap($newPriceId);
return [
'provider_subscription_id' => $cashierSubscription->stripe_id,
'status' => $cashierSubscription->stripe_status,
'new_price_id' => $newPriceId,
];
} catch (Exception $e) {
Log::error('Stripe subscription update failed', [
'subscription_id' => $subscription->id,
'new_plan_id' => $newPlan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function pauseSubscription(Subscription $subscription): bool
{
try {
$cashierSubscription = CashierSubscription::find($subscription->stripe_id);
if (! $cashierSubscription) {
throw new Exception('Cashier subscription not found');
}
// Stripe doesn't have a native pause feature, so we cancel at period end
$cashierSubscription->cancel();
return true;
} catch (Exception $e) {
Log::error('Stripe subscription pause failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function resumeSubscription(Subscription $subscription): bool
{
try {
$cashierSubscription = CashierSubscription::find($subscription->stripe_id);
if (! $cashierSubscription) {
throw new Exception('Cashier subscription not found');
}
// Resume cancelled subscription
$cashierSubscription->resume();
return true;
} catch (Exception $e) {
Log::error('Stripe subscription resume failed', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function getSubscriptionDetails(string $providerSubscriptionId): array
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($providerSubscriptionId);
return [
'id' => $stripeSubscription->id,
'status' => $stripeSubscription->status,
'current_period_start' => $stripeSubscription->current_period_start,
'current_period_end' => $stripeSubscription->current_period_end,
'trial_start' => $stripeSubscription->trial_start,
'trial_end' => $stripeSubscription->trial_end,
'customer' => $stripeSubscription->customer,
'items' => $stripeSubscription->items->data,
'created' => $stripeSubscription->created,
'ended_at' => $stripeSubscription->ended_at,
'canceled_at' => $stripeSubscription->canceled_at,
];
} catch (Exception $e) {
Log::error('Stripe subscription details retrieval failed', [
'subscription_id' => $providerSubscriptionId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function createCheckoutSession(User $user, Plan $plan, array $options = []): array
{
try {
$customer = $this->getOrCreateCustomer($user);
$priceId = $this->getOrCreatePrice($plan);
$sessionData = [
'customer' => $customer->id,
'payment_method_types' => ['card'],
'line_items' => [[
'price' => $priceId,
'quantity' => 1,
]],
'mode' => $plan->monthly_billing ? 'subscription' : 'payment',
'success_url' => $options['success_url'] ?? $this->config['success_url'],
'cancel_url' => $options['cancel_url'] ?? $this->config['cancel_url'],
'metadata' => [
'user_id' => $user->id,
'plan_id' => $plan->id,
],
];
if ($plan->monthly_billing && ! empty($options['trial_days'])) {
$sessionData['subscription_data'] = [
'trial_period_days' => $options['trial_days'],
];
}
if (! empty($options['coupon'])) {
$sessionData['discounts'] = [['coupon' => $options['coupon']]];
}
$session = Session::create($sessionData);
return [
'checkout_session_id' => $session->id,
'checkout_url' => $session->url,
];
} catch (Exception $e) {
Log::error('Stripe checkout session creation failed', [
'user_id' => $user->id,
'plan_id' => $plan->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function createCustomerPortalSession(User $user): array
{
try {
$customer = $this->getOrCreateCustomer($user);
$session = \Stripe\BillingPortal\Session::create([
'customer' => $customer->id,
'return_url' => route('dashboard'),
]);
return [
'portal_session_id' => $session->id,
'portal_url' => $session->url,
];
} catch (Exception $e) {
Log::error('Stripe customer portal session creation failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function processWebhook(Request $request): array
{
try {
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
$event = \Stripe\Event::constructFrom(
json_decode($payload, true)
);
$result = [
'event_type' => $event->type,
'processed' => false,
'data' => [],
];
switch ($event->type) {
case 'invoice.payment_succeeded':
$result = $this->handleInvoicePaymentSucceeded($event);
break;
case 'invoice.payment_failed':
$result = $this->handleInvoicePaymentFailed($event);
break;
case 'customer.subscription.created':
$result = $this->handleSubscriptionCreated($event);
break;
case 'customer.subscription.updated':
$result = $this->handleSubscriptionUpdated($event);
break;
case 'customer.subscription.deleted':
$result = $this->handleSubscriptionDeleted($event);
break;
default:
Log::info('Unhandled Stripe webhook event', ['event_type' => $event->type]);
}
return $result;
} catch (Exception $e) {
Log::error('Stripe webhook processing failed', [
'error' => $e->getMessage(),
'payload' => $request->getContent(),
]);
throw $e;
}
}
public function validateWebhook(Request $request): bool
{
try {
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
if (! $sigHeader) {
return false;
}
\Stripe\Webhook::constructEvent(
$payload,
$sigHeader,
$this->config['webhook_secret']
);
return true;
} catch (Exception $e) {
Log::warning('Stripe 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 {
$paymentMethod = \Stripe\PaymentMethod::retrieve($paymentMethodId);
return [
'id' => $paymentMethod->id,
'type' => $paymentMethod->type,
'card' => [
'brand' => $paymentMethod->card->brand,
'last4' => $paymentMethod->card->last4,
'exp_month' => $paymentMethod->card->exp_month,
'exp_year' => $paymentMethod->card->exp_year,
],
'created' => $paymentMethod->created,
];
} catch (Exception $e) {
Log::error('Stripe payment method details retrieval failed', [
'payment_method_id' => $paymentMethodId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function processRefund(string $paymentIntentId, float $amount, string $reason = ''): array
{
try {
$refund = \Stripe\Refund::create([
'payment_intent' => $paymentIntentId,
'amount' => (int) ($amount * 100), // Convert to cents
'reason' => $reason ?: 'requested_by_customer',
]);
return [
'refund_id' => $refund->id,
'amount' => $refund->amount / 100,
'status' => $refund->status,
'created' => $refund->created,
];
} catch (Exception $e) {
Log::error('Stripe refund processing failed', [
'payment_intent_id' => $paymentIntentId,
'amount' => $amount,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function getTransactionHistory(User $user, array $filters = []): array
{
try {
$customer = $this->getOrCreateCustomer($user);
$charges = \Stripe\Charge::all([
'customer' => $customer->id,
'limit' => $filters['limit'] ?? 100,
'starting_after' => $filters['starting_after'] ?? null,
]);
$transactions = [];
foreach ($charges->data as $charge) {
$transactions[] = [
'id' => $charge->id,
'amount' => $charge->amount / 100,
'currency' => $charge->currency,
'status' => $charge->status,
'created' => $charge->created,
'description' => $charge->description,
'payment_method' => $charge->payment_method,
];
}
return $transactions;
} catch (Exception $e) {
Log::error('Stripe transaction history retrieval failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function calculateFees(float $amount): array
{
// Stripe fees: 2.9% + $0.30 (US), varies by country
$fixedFee = 0.30; // $0.30
$percentageFee = 2.9; // 2.9%
$percentageAmount = ($amount * $percentageFee) / 100;
$totalFee = $fixedFee + $percentageAmount;
return [
'fixed_fee' => $fixedFee,
'percentage_fee' => $percentageAmount,
'total_fee' => $totalFee,
'net_amount' => $amount - $totalFee,
];
}
public function getSupportedCurrencies(): array
{
return [
'USD', 'EUR', 'GBP', 'CAD', 'AUD', 'CHF', 'SEK', 'NOK', 'DKK',
'PLN', 'CZK', 'HUF', 'RON', 'BGN', 'HRK', 'RUB', 'TRY', 'MXN',
'BRL', 'ARS', 'CLP', 'COP', 'PEN', 'UYU', 'JPY', 'SGD', 'HKD',
'INR', 'MYR', 'THB', 'PHP', 'TWD', 'KRW', 'CNY',
];
}
public function supportsRecurring(): bool
{
return true;
}
public function supportsOneTime(): bool
{
return true;
}
// Helper methods
protected function getOrCreateCustomer(User $user): Customer
{
if ($user->stripe_id) {
return Customer::retrieve($user->stripe_id);
}
$customer = Customer::create([
'email' => $user->email,
'name' => $user->name,
'metadata' => [
'user_id' => $user->id,
],
]);
$user->update(['stripe_id' => $customer->id]);
return $customer;
}
protected function getOrCreatePrice(Plan $plan): string
{
// Check if plan already has a Stripe price ID
if (! empty($plan->details['stripe_price_id'])) {
return $plan->details['stripe_price_id'];
}
// Create product if it doesn't exist
$productId = $this->getOrCreateProduct($plan);
// Create price
$priceData = [
'product' => $productId,
'unit_amount' => intval($plan->price * 100), // Convert to cents
'currency' => strtolower($this->config['currency']),
];
if ($plan->monthly_billing) {
$priceData['recurring'] = [
'interval' => 'month',
'interval_count' => 1,
];
}
$price = Price::create($priceData);
// Update plan with new price ID
$planDetails = $plan->details ?? [];
$planDetails['stripe_price_id'] = $price->id;
$plan->update(['details' => $planDetails]);
return $price->id;
}
protected function getOrCreateProduct(Plan $plan): string
{
// Check if plan already has a Stripe product ID
if (! empty($plan->details['stripe_product_id'])) {
return $plan->details['stripe_product_id'];
}
// Create product
$product = Product::create([
'name' => $plan->name,
'description' => $plan->description,
'metadata' => [
'plan_id' => $plan->id,
],
]);
// Update plan with new product ID
$planDetails = $plan->details ?? [];
$planDetails['stripe_product_id'] = $product->id;
$plan->update(['details' => $planDetails]);
return $product->id;
}
// Webhook handlers
protected function handleInvoicePaymentSucceeded($event): array
{
$invoice = $event->data->object;
return [
'event_type' => $event->type,
'processed' => true,
'data' => [
'invoice_id' => $invoice->id,
'subscription_id' => $invoice->subscription,
'amount_paid' => $invoice->amount_paid / 100,
'status' => 'paid',
],
];
}
protected function handleInvoicePaymentFailed($event): array
{
$invoice = $event->data->object;
return [
'event_type' => $event->type,
'processed' => true,
'data' => [
'invoice_id' => $invoice->id,
'subscription_id' => $invoice->subscription,
'attempt_count' => $invoice->attempt_count,
'status' => 'payment_failed',
],
];
}
protected function handleSubscriptionCreated($event): array
{
$subscription = $event->data->object;
return [
'event_type' => $event->type,
'processed' => true,
'data' => [
'subscription_id' => $subscription->id,
'customer_id' => $subscription->customer,
'status' => $subscription->status,
],
];
}
protected function handleSubscriptionUpdated($event): array
{
$subscription = $event->data->object;
return [
'event_type' => $event->type,
'processed' => true,
'data' => [
'subscription_id' => $subscription->id,
'status' => $subscription->status,
'current_period_start' => $subscription->current_period_start,
'current_period_end' => $subscription->current_period_end,
],
];
}
protected function handleSubscriptionDeleted($event): array
{
$subscription = $event->data->object;
return [
'event_type' => $event->type,
'processed' => true,
'data' => [
'subscription_id' => $subscription->id,
'status' => 'canceled',
'ended_at' => $subscription->ended_at,
],
];
}
// Additional interface methods
public function getSubscriptionMetadata(Subscription $subscription): array
{
return $subscription->provider_data['metadata'] ?? [];
}
public function updateSubscriptionMetadata(Subscription $subscription, array $metadata): bool
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
$stripeSubscription->metadata = $metadata;
$stripeSubscription->save();
return true;
} catch (Exception $e) {
Log::error('Failed to update subscription metadata', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function startTrial(Subscription $subscription, int $trialDays): bool
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
$stripeSubscription->trial_end = now()->addDays($trialDays)->timestamp;
$stripeSubscription->save();
return true;
} catch (Exception $e) {
Log::error('Failed to start trial', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function applyCoupon(Subscription $subscription, string $couponCode): array
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
$stripeSubscription->discount = null; // Remove existing discount
$stripeSubscription->save();
// Apply new coupon
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
$coupon = \Stripe\Coupon::retrieve($couponCode);
return [
'coupon_id' => $coupon->id,
'amount_off' => $coupon->amount_off ?? null,
'percent_off' => $coupon->percent_off ?? null,
];
} catch (Exception $e) {
Log::error('Failed to apply coupon', [
'subscription_id' => $subscription->id,
'coupon_code' => $couponCode,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function removeCoupon(Subscription $subscription): bool
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
$stripeSubscription->deleteDiscount();
return true;
} catch (Exception $e) {
Log::error('Failed to remove coupon', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
return false;
}
}
public function getUpcomingInvoice(Subscription $subscription): array
{
try {
$invoice = \Stripe\Invoice::upcoming(['subscription' => $subscription->provider_subscription_id]);
return [
'amount_due' => $invoice->amount_due / 100,
'currency' => $invoice->currency,
'period_start' => $invoice->period_start,
'period_end' => $invoice->period_end,
'lines' => $invoice->lines->data,
];
} catch (Exception $e) {
Log::error('Failed to get upcoming invoice', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function retryFailedPayment(Subscription $subscription): array
{
try {
$invoice = \Stripe\Invoice::retrieve(['subscription' => $subscription->provider_subscription_id]);
$invoice->pay();
return [
'invoice_id' => $invoice->id,
'status' => $invoice->status,
'amount_paid' => $invoice->amount_paid / 100,
];
} catch (Exception $e) {
Log::error('Failed to retry payment', [
'subscription_id' => $subscription->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function canModifySubscription(Subscription $subscription): bool
{
try {
$stripeSubscription = \Stripe\Subscription::retrieve($subscription->provider_subscription_id);
return in_array($stripeSubscription->status, ['active', 'trialing']);
} catch (Exception $e) {
return false;
}
}
public function getCancellationTerms(Subscription $subscription): array
{
return [
'immediate_cancellation' => true,
'refund_policy' => 'pro_rata',
'cancellation_effective' => 'immediately',
'billing_cycle_proration' => true,
];
}
public function exportSubscriptionData(Subscription $subscription): array
{
return [
'provider' => 'stripe',
'provider_subscription_id' => $subscription->provider_subscription_id,
'stripe_id' => $subscription->stripe_id,
'status' => $subscription->stripe_status,
'data' => $subscription->provider_data,
];
}
public function importSubscriptionData(User $user, array $subscriptionData): array
{
// This would be used for migrating subscriptions to Stripe
// Implementation depends on specific requirements
throw new Exception('Import to Stripe not implemented');
}
}