php-commerce/Services/PaymentGateway/StripeGateway.php
2026-01-27 00:24:22 +00:00

656 lines
23 KiB
PHP

<?php
namespace Core\Commerce\Services\PaymentGateway;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Log;
use Core\Commerce\Models\Order;
use Core\Commerce\Models\Payment;
use Core\Commerce\Models\PaymentMethod;
use Core\Commerce\Models\Refund;
use Core\Commerce\Models\Subscription;
use Stripe\StripeClient;
/**
* Stripe payment gateway implementation.
*
* Secondary gateway - implemented but not exposed to users initially.
*/
class StripeGateway implements PaymentGatewayContract
{
protected ?StripeClient $stripe = null;
protected string $webhookSecret;
public function __construct()
{
$secret = config('commerce.gateways.stripe.secret');
if ($secret) {
$this->stripe = new StripeClient($secret);
}
$this->webhookSecret = config('commerce.gateways.stripe.webhook_secret') ?? '';
}
/**
* Get the Stripe client instance.
*
* @throws \RuntimeException If Stripe is not configured.
*/
protected function getStripe(): StripeClient
{
if (! $this->stripe) {
throw new \RuntimeException('Stripe is not configured. Please set STRIPE_SECRET in your environment.');
}
return $this->stripe;
}
public function getIdentifier(): string
{
return 'stripe';
}
public function isEnabled(): bool
{
return config('commerce.gateways.stripe.enabled', false)
&& $this->stripe !== null;
}
// Customer Management
public function createCustomer(Workspace $workspace): string
{
$customer = $this->getStripe()->customers->create([
'name' => $workspace->billing_name ?? $workspace->name,
'email' => $workspace->billing_email,
'address' => [
'line1' => $workspace->billing_address_line1,
'line2' => $workspace->billing_address_line2,
'city' => $workspace->billing_city,
'state' => $workspace->billing_state,
'postal_code' => $workspace->billing_postal_code,
'country' => $workspace->billing_country,
],
'metadata' => [
'workspace_id' => $workspace->id,
'workspace_slug' => $workspace->slug,
],
]);
$workspace->update(['stripe_customer_id' => $customer->id]);
return $customer->id;
}
public function updateCustomer(Workspace $workspace): void
{
if (! $workspace->stripe_customer_id) {
return;
}
$this->getStripe()->customers->update($workspace->stripe_customer_id, [
'name' => $workspace->billing_name ?? $workspace->name,
'email' => $workspace->billing_email,
'address' => [
'line1' => $workspace->billing_address_line1,
'line2' => $workspace->billing_address_line2,
'city' => $workspace->billing_city,
'state' => $workspace->billing_state,
'postal_code' => $workspace->billing_postal_code,
'country' => $workspace->billing_country,
],
]);
}
// Checkout
public function createCheckoutSession(Order $order, string $successUrl, string $cancelUrl): array
{
try {
$lineItems = $this->buildLineItems($order);
// Ensure customer exists
$customerId = $order->workspace->stripe_customer_id;
if (! $customerId) {
$customerId = $this->createCustomer($order->workspace);
}
$sessionParams = [
'customer' => $customerId,
'line_items' => $lineItems,
'mode' => $this->hasRecurringItems($order) ? 'subscription' : 'payment',
'success_url' => $successUrl.'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => $cancelUrl,
'metadata' => [
'order_id' => $order->id,
'order_number' => $order->order_number,
'workspace_id' => $order->workspace_id,
],
'automatic_tax' => ['enabled' => false], // We handle tax ourselves
'allow_promotion_codes' => false, // We handle coupons ourselves
];
// Add discount if applicable
if ($order->discount_amount > 0 && $order->coupon) {
$sessionParams['discounts'] = [['coupon' => $this->createOrderCoupon($order)]];
}
$session = $this->getStripe()->checkout->sessions->create($sessionParams);
$order->update(['gateway_session_id' => $session->id]);
return [
'session_id' => $session->id,
'checkout_url' => $session->url,
];
} catch (\Stripe\Exception\CardException $e) {
Log::warning('Stripe checkout failed: card error', [
'order_id' => $order->id,
'error' => $e->getMessage(),
'code' => $e->getStripeCode(),
]);
throw new \RuntimeException('Payment card error: '.$e->getMessage(), 0, $e);
} catch (\Stripe\Exception\RateLimitException $e) {
Log::error('Stripe checkout failed: rate limit', [
'order_id' => $order->id,
]);
throw new \RuntimeException('Payment service temporarily unavailable. Please try again.', 0, $e);
} catch (\Stripe\Exception\InvalidRequestException $e) {
Log::error('Stripe checkout failed: invalid request', [
'order_id' => $order->id,
'error' => $e->getMessage(),
'param' => $e->getStripeParam(),
]);
throw new \RuntimeException('Unable to create checkout session. Please contact support.', 0, $e);
} catch (\Stripe\Exception\AuthenticationException $e) {
Log::critical('Stripe authentication failed - check API keys', [
'order_id' => $order->id,
]);
throw new \RuntimeException('Payment service configuration error. Please contact support.', 0, $e);
} catch (\Stripe\Exception\ApiConnectionException $e) {
Log::error('Stripe checkout failed: connection error', [
'order_id' => $order->id,
]);
throw new \RuntimeException('Unable to connect to payment service. Please try again.', 0, $e);
} catch (\Stripe\Exception\ApiErrorException $e) {
Log::error('Stripe checkout failed: API error', [
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
throw new \RuntimeException('Payment service error. Please try again or contact support.', 0, $e);
}
}
/**
* Build line items array for Stripe checkout session.
*/
protected function buildLineItems(Order $order): array
{
$lineItems = [];
foreach ($order->items as $item) {
$lineItem = [
'price_data' => [
'currency' => strtolower($order->currency),
'product_data' => [
'name' => $item->name,
],
'unit_amount' => (int) round($item->unit_price * 100),
],
'quantity' => $item->quantity,
];
// Only add description if present (Stripe rejects empty strings)
if (! empty($item->description)) {
$lineItem['price_data']['product_data']['description'] = $item->description;
}
// Add recurring config if applicable
if ($item->billing_cycle) {
$lineItem['price_data']['recurring'] = [
'interval' => $item->billing_cycle === 'yearly' ? 'year' : 'month',
];
}
$lineItems[] = $lineItem;
}
return $lineItems;
}
/**
* Create a one-time Stripe coupon for an order discount.
*/
protected function createOrderCoupon(Order $order): string
{
$stripeCoupon = $this->getStripe()->coupons->create([
'amount_off' => (int) round($order->discount_amount * 100),
'currency' => strtolower($order->currency),
'duration' => 'once',
'name' => $order->coupon->code,
]);
return $stripeCoupon->id;
}
public function getCheckoutSession(string $sessionId): array
{
$session = $this->getStripe()->checkout->sessions->retrieve($sessionId, [
'expand' => ['payment_intent', 'subscription'],
]);
return [
'id' => $session->id,
'status' => $this->mapSessionStatus($session->status),
'amount' => $session->amount_total / 100,
'currency' => strtoupper($session->currency),
'paid_at' => $session->payment_status === 'paid' ? now() : null,
'subscription_id' => $session->subscription?->id,
'payment_intent_id' => $session->payment_intent?->id,
'metadata' => (array) $session->metadata,
'raw' => $session,
];
}
// Payments
public function charge(Workspace $workspace, int $amountCents, string $currency, array $metadata = []): Payment
{
$customerId = $workspace->stripe_customer_id;
if (! $customerId) {
$customerId = $this->createCustomer($workspace);
}
$paymentIntent = $this->getStripe()->paymentIntents->create([
'amount' => $amountCents,
'currency' => strtolower($currency),
'customer' => $customerId,
'metadata' => array_merge($metadata, ['workspace_id' => $workspace->id]),
'automatic_payment_methods' => ['enabled' => true],
]);
return Payment::create([
'workspace_id' => $workspace->id,
'gateway' => 'stripe',
'gateway_payment_id' => $paymentIntent->id,
'amount' => $amountCents / 100,
'currency' => strtoupper($currency),
'status' => $this->mapPaymentIntentStatus($paymentIntent->status),
'gateway_response' => $paymentIntent->toArray(),
]);
}
public function chargePaymentMethod(PaymentMethod $paymentMethod, int $amountCents, string $currency, array $metadata = []): Payment
{
$workspace = $paymentMethod->workspace;
$paymentIntent = $this->getStripe()->paymentIntents->create([
'amount' => $amountCents,
'currency' => strtolower($currency),
'customer' => $workspace->stripe_customer_id,
'payment_method' => $paymentMethod->gateway_payment_method_id,
'off_session' => true,
'confirm' => true,
'metadata' => array_merge($metadata, ['workspace_id' => $workspace->id]),
]);
return Payment::create([
'workspace_id' => $workspace->id,
'gateway' => 'stripe',
'gateway_payment_id' => $paymentIntent->id,
'payment_method_id' => $paymentMethod->id,
'amount' => $amountCents / 100,
'currency' => strtoupper($currency),
'status' => $this->mapPaymentIntentStatus($paymentIntent->status),
'gateway_response' => $paymentIntent->toArray(),
]);
}
// Subscriptions
public function createSubscription(Workspace $workspace, string $priceId, array $options = []): Subscription
{
$customerId = $workspace->stripe_customer_id;
if (! $customerId) {
$customerId = $this->createCustomer($workspace);
}
$params = [
'customer' => $customerId,
'items' => [['price' => $priceId]],
'metadata' => ['workspace_id' => $workspace->id],
];
if (isset($options['trial_days']) && $options['trial_days'] > 0) {
$params['trial_period_days'] = $options['trial_days'];
}
if (isset($options['coupon'])) {
$params['coupon'] = $options['coupon'];
}
$stripeSubscription = $this->getStripe()->subscriptions->create($params);
return Subscription::create([
'workspace_id' => $workspace->id,
'gateway' => 'stripe',
'gateway_subscription_id' => $stripeSubscription->id,
'gateway_customer_id' => $customerId,
'gateway_price_id' => $priceId,
'status' => $this->mapSubscriptionStatus($stripeSubscription->status),
'current_period_start' => \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_start),
'current_period_end' => \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_end),
'trial_ends_at' => $stripeSubscription->trial_end
? \Carbon\Carbon::createFromTimestamp($stripeSubscription->trial_end)
: null,
'metadata' => ['stripe_subscription' => $stripeSubscription->toArray()],
]);
}
public function updateSubscription(Subscription $subscription, array $options): Subscription
{
$params = [];
if (isset($options['price_id'])) {
$params['items'] = [
[
'id' => $this->getSubscriptionItemId($subscription),
'price' => $options['price_id'],
],
];
$params['proration_behavior'] = ($options['prorate'] ?? true)
? 'create_prorations'
: 'none';
}
if (isset($options['cancel_at_period_end'])) {
$params['cancel_at_period_end'] = $options['cancel_at_period_end'];
}
$stripeSubscription = $this->getStripe()->subscriptions->update(
$subscription->gateway_subscription_id,
$params
);
$subscription->update([
'gateway_price_id' => $options['price_id'] ?? $subscription->gateway_price_id,
'status' => $this->mapSubscriptionStatus($stripeSubscription->status),
'cancel_at_period_end' => $stripeSubscription->cancel_at_period_end,
'current_period_start' => \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_start),
'current_period_end' => \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_end),
]);
return $subscription->fresh();
}
public function cancelSubscription(Subscription $subscription, bool $immediately = false): void
{
if ($immediately) {
$this->getStripe()->subscriptions->cancel($subscription->gateway_subscription_id);
$subscription->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'ended_at' => now(),
]);
} else {
$this->getStripe()->subscriptions->update($subscription->gateway_subscription_id, [
'cancel_at_period_end' => true,
]);
$subscription->update([
'cancel_at_period_end' => true,
'cancelled_at' => now(),
]);
}
}
public function resumeSubscription(Subscription $subscription): void
{
$this->getStripe()->subscriptions->update($subscription->gateway_subscription_id, [
'cancel_at_period_end' => false,
]);
$subscription->resume();
}
public function pauseSubscription(Subscription $subscription): void
{
$this->getStripe()->subscriptions->update($subscription->gateway_subscription_id, [
'pause_collection' => ['behavior' => 'void'],
]);
$subscription->pause();
}
// Payment Methods
public function createSetupSession(Workspace $workspace, string $returnUrl): array
{
$customerId = $workspace->stripe_customer_id;
if (! $customerId) {
$customerId = $this->createCustomer($workspace);
}
$session = $this->getStripe()->checkout->sessions->create([
'customer' => $customerId,
'mode' => 'setup',
'success_url' => $returnUrl.'?setup_intent={SETUP_INTENT}',
'cancel_url' => $returnUrl,
]);
return [
'session_id' => $session->id,
'setup_url' => $session->url,
];
}
public function attachPaymentMethod(Workspace $workspace, string $gatewayPaymentMethodId): PaymentMethod
{
$stripePaymentMethod = $this->getStripe()->paymentMethods->attach($gatewayPaymentMethodId, [
'customer' => $workspace->stripe_customer_id,
]);
return PaymentMethod::create([
'workspace_id' => $workspace->id,
'gateway' => 'stripe',
'gateway_payment_method_id' => $stripePaymentMethod->id,
'type' => $stripePaymentMethod->type,
'last_four' => $stripePaymentMethod->card?->last4,
'brand' => $stripePaymentMethod->card?->brand,
'exp_month' => $stripePaymentMethod->card?->exp_month,
'exp_year' => $stripePaymentMethod->card?->exp_year,
'is_default' => false,
]);
}
public function detachPaymentMethod(PaymentMethod $paymentMethod): void
{
$this->getStripe()->paymentMethods->detach($paymentMethod->gateway_payment_method_id);
// Don't delete - the PaymentMethodService handles deactivation
}
public function setDefaultPaymentMethod(PaymentMethod $paymentMethod): void
{
$this->getStripe()->customers->update($paymentMethod->workspace->stripe_customer_id, [
'invoice_settings' => [
'default_payment_method' => $paymentMethod->gateway_payment_method_id,
],
]);
// Update local records
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
{
$amountCents = (int) round($amount * 100);
try {
$stripeRefund = $this->getStripe()->refunds->create([
'payment_intent' => $payment->gateway_payment_id,
'amount' => $amountCents,
'reason' => $this->mapRefundReason($reason),
]);
$refund = Refund::create([
'payment_id' => $payment->id,
'gateway_refund_id' => $stripeRefund->id,
'amount' => $amount,
'currency' => $payment->currency,
'status' => $stripeRefund->status === 'succeeded' ? 'succeeded' : 'pending',
'reason' => $reason,
'gateway_response' => $stripeRefund->toArray(),
]);
if ($stripeRefund->status === 'succeeded') {
$refund->markAsSucceeded($stripeRefund->id);
}
return [
'success' => true,
'refund_id' => $stripeRefund->id,
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
// Invoices
public function getInvoice(string $gatewayInvoiceId): array
{
$invoice = $this->getStripe()->invoices->retrieve($gatewayInvoiceId);
return $invoice->toArray();
}
public function getInvoicePdfUrl(string $gatewayInvoiceId): ?string
{
$invoice = $this->getStripe()->invoices->retrieve($gatewayInvoiceId);
return $invoice->invoice_pdf;
}
// Webhooks
public function verifyWebhookSignature(string $payload, string $signature): bool
{
try {
\Stripe\Webhook::constructEvent($payload, $signature, $this->webhookSecret);
return true;
} catch (\Exception $e) {
Log::warning('Stripe webhook signature verification failed', ['error' => $e->getMessage()]);
return false;
}
}
public function parseWebhookEvent(string $payload): array
{
$event = json_decode($payload, true);
return [
'type' => $event['type'] ?? 'unknown',
'id' => $event['data']['object']['id'] ?? null,
'object_type' => $event['data']['object']['object'] ?? null,
'metadata' => $event['data']['object']['metadata'] ?? [],
'raw' => $event,
];
}
// Tax
public function createTaxRate(string $name, float $percentage, string $country, bool $inclusive = false): string
{
$taxRate = $this->getStripe()->taxRates->create([
'display_name' => $name,
'percentage' => $percentage,
'country' => $country,
'inclusive' => $inclusive,
]);
return $taxRate->id;
}
// Portal
public function getPortalUrl(Workspace $workspace, string $returnUrl): ?string
{
if (! $workspace->stripe_customer_id) {
return null;
}
$session = $this->getStripe()->billingPortal->sessions->create([
'customer' => $workspace->stripe_customer_id,
'return_url' => $returnUrl,
]);
return $session->url;
}
// Helper Methods
protected function hasRecurringItems(Order $order): bool
{
return $order->items->contains(fn ($item) => $item->billing_cycle !== null);
}
protected function getSubscriptionItemId(Subscription $subscription): string
{
$stripeSubscription = $this->getStripe()->subscriptions->retrieve($subscription->gateway_subscription_id);
return $stripeSubscription->items->data[0]->id;
}
protected function mapSessionStatus(string $status): string
{
return match ($status) {
'complete' => 'succeeded',
'expired' => 'expired',
'open' => 'pending',
default => 'pending',
};
}
protected function mapPaymentIntentStatus(string $status): string
{
return match ($status) {
'succeeded' => 'succeeded',
'processing' => 'processing',
'requires_payment_method', 'requires_confirmation', 'requires_action' => 'pending',
'canceled' => 'failed',
default => 'pending',
};
}
protected function mapSubscriptionStatus(string $status): string
{
return match ($status) {
'active' => 'active',
'trialing' => 'trialing',
'past_due' => 'past_due',
'paused' => 'paused',
'canceled' => 'cancelled',
'incomplete', 'incomplete_expired' => 'incomplete',
default => 'active',
};
}
protected function mapRefundReason(?string $reason): string
{
return match ($reason) {
'duplicate' => 'duplicate',
'fraudulent' => 'fraudulent',
default => 'requested_by_customer',
};
}
}