Stripe's minor-unit int going INTO a gateway * call (StripeManager::toStripeAmount()), Stripe's response amount -> * Lunar's Price coming back OUT (StripeManager::fromStripeAmount()). * Nothing outside this class ever sees a Stripe-scaled integer. * * Correlating a later handleCallback() (a separate request — a webhook) * back to whatever $context identified this attempt is solved via real * cart_id/order_id columns on Modules\Core\Payment\Models\ * StripePaymentIntent (a table this app now owns outright, already shaped * for exactly this), not a generic context blob. See docs/payments.md * "Async resolution" for the full reasoning. */ class StripePaymentDriver implements Configurable, SupportsPay, SupportsAuthorization, SupportsCaptures, SupportsVoids, SupportsRefunds, HandlesPaymentCallback { public function __construct( private readonly StripeManager $stripe, ) {} /** * Same key StripeManager reads its API key from — no key, no usable * driver. */ public function isConfigured(): bool { return filled(config('services.stripe.key')); } /** * Atomic charge — capture_method: automatic. Stripe still frequently * confirms into requires_action/requires_confirmation rather than * succeeded in the same call (3-D Secure, most real cards) — Pending * is a normal outcome here, not an edge case, resolved later via * handleCallback(). */ public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult { return $this->createAndConfirm($type, $amount, $data, $context, captureMethod: 'automatic'); } /** * Hold only — capture_method: manual. Resolves to Pending or an * authorized (requires_capture) intent, never succeeded directly: * Stripe never captures on its own for a manual intent. */ public function authorize(string $type, Price $amount, array $data = [], array $context = []): PaymentResult { return $this->createAndConfirm($type, $amount, $data, $context, captureMethod: 'manual'); } private function createAndConfirm(string $type, Price $amount, array $data, array $context, string $captureMethod): PaymentResult { $params = [ 'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency), 'currency' => $amount->currency->code, 'capture_method' => $captureMethod, 'confirm' => true, // 'never' rather than the client-side paymentMethodTypes: ['card'] // restriction alone — the storefront's Payment Element already // excludes every redirect-based method, but without this Stripe // still falls back to whatever's enabled in the Dashboard and // demands a return_url on confirm. Setting this unconditionally // (not only when no payment_method is given) matches the actual // flow: a payment_method is always supplied here. 'automatic_payment_methods' => ['enabled' => true, 'allow_redirects' => 'never'], ]; if (isset($data['payment_method'])) { $params['payment_method'] = $data['payment_method']; } try { $paymentIntent = $this->stripe->getClient()->paymentIntents->create($params); } catch (ApiErrorException $e) { return $this->declined($type, $amount, $e, $context, authorizing: $captureMethod === 'manual'); } $this->rememberIntent($paymentIntent, $type, $context); return $this->resultFromIntent($type, $paymentIntent, $amount, $context, authorizing: $captureMethod === 'manual'); } public function handleCallback(string $reference, array $data, array $context = []): PaymentResult { [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context, $data['type'] ?? ''); $paymentIntent = $this->stripe->getClient()->paymentIntents->retrieve($reference); $authorizing = $paymentIntent->capture_method === PaymentIntent::CAPTURE_METHOD_MANUAL; if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_CAPTURE && ! $authorizing) { // automatic capture_method, but Stripe stopped short of // capturing (rare, but the API contract allows it) — finish // the job pay() started. $paymentIntent = $this->stripe->getClient()->paymentIntents->capture($reference); } $intentModel?->update(['status' => $paymentIntent->status]); $amount = $this->priceFromIntent($paymentIntent); return $this->resultFromIntent($type, $paymentIntent, $amount, $context, $authorizing); } public function capture(string $reference, Price $amount, array $context = []): PaymentResult { [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context); try { $paymentIntent = $this->stripe->getClient()->paymentIntents->capture($reference, [ 'amount_to_capture' => StripeManager::toStripeAmount($amount->value, $amount->currency), ]); } catch (ApiErrorException $e) { $result = $this->failure($amount, $e, $reference); PaymentCaptureFailed::dispatch($type, $result, $context); return $result; } $intentModel?->update(['status' => $paymentIntent->status]); $result = new PaymentResult( status: $paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED ? PaymentResultStatus::Succeeded : PaymentResultStatus::Failed, reference: $paymentIntent->id, amount: $amount, raw: $paymentIntent->toArray(), meta: $this->cardMetaFromIntent($paymentIntent), ); $paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED ? PaymentCaptured::dispatch($type, $result, $context) : PaymentCaptureFailed::dispatch($type, $result, $context); return $result; } public function void(string $reference, Price $amount, array $context = []): PaymentResult { [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context); try { $paymentIntent = $this->stripe->getClient()->paymentIntents->cancel($reference); } catch (ApiErrorException $e) { $result = $this->failure($amount, $e, $reference); PaymentVoidFailed::dispatch($type, $result, $context); return $result; } $intentModel?->update(['status' => $paymentIntent->status]); $result = new PaymentResult( status: $paymentIntent->status === PaymentIntent::STATUS_CANCELED ? PaymentResultStatus::Succeeded : PaymentResultStatus::Failed, reference: $paymentIntent->id, amount: $amount, raw: $paymentIntent->toArray(), ); $paymentIntent->status === PaymentIntent::STATUS_CANCELED ? PaymentVoided::dispatch($type, $result, $context) : PaymentVoidFailed::dispatch($type, $result, $context); return $result; } public function refund(string $reference, Price $amount, array $context = []): PaymentResult { [$intentModel, $type, $context] = $this->resolveIntentModel($reference, $context); try { $refund = $this->stripe->getClient()->refunds->create([ 'payment_intent' => $reference, 'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency), ]); } catch (ApiErrorException $e) { $result = $this->failure($amount, $e, $reference); PaymentRefundFailed::dispatch($type, $result, $context); return $result; } $result = new PaymentResult( status: $refund->status !== 'failed' ? PaymentResultStatus::Succeeded : PaymentResultStatus::Failed, reference: $refund->id, amount: $amount, raw: $refund->toArray(), ); $refund->status !== 'failed' ? PaymentRefunded::dispatch($type, $result, $context) : PaymentRefundFailed::dispatch($type, $result, $context); return $result; } private function rememberIntent(PaymentIntent $paymentIntent, string $type, array $context): ?StripePaymentIntent { if (! ($context['cart_id'] ?? null)) { return null; } return StripePaymentIntent::create([ 'intent_id' => $paymentIntent->id, 'cart_id' => $context['cart_id'], 'order_id' => $context['order_id'] ?? null, 'status' => $paymentIntent->status, 'payment_type' => $type, 'context' => $context, ]); } /** * The one lookup every method past initiate() shares: find the * StripePaymentIntent row this $reference belongs to, then recover * $type/$context from it — the original context, if any, always * takes precedence over whatever the caller passed in (see * handleCallback()'s own note: a webhook caller usually has none of * its own). * * $typeFallback only matters when there's no $intentModel to read * payment_type from — handleCallback() has its own $data['type'] to * fall back to; capture()/void()/refund() have nothing better than ''. * * @return array{0: ?StripePaymentIntent, 1: string, 2: array} */ private function resolveIntentModel(string $reference, array $context, string $typeFallback = ''): array { $intentModel = StripePaymentIntent::where('intent_id', $reference)->first(); return [ $intentModel, $intentModel?->payment_type ?? $typeFallback, $intentModel?->context ?? $context, ]; } /** * Converts a live Stripe PaymentIntent's own amount/currency back * into Lunar's Price — the one place this class reads a Stripe * response's amount without already holding the Price that produced * it (handleCallback() has no $data['amount'] to fall back on, unlike * pay()/authorize()). */ private function priceFromIntent(PaymentIntent $paymentIntent): Price { $currency = Currency::whereRaw('lower(code) = ?', [strtolower($paymentIntent->currency)])->firstOrFail(); return new Price( (int) StripeManager::fromStripeAmount($paymentIntent->amount, $currency), $currency, ); } private function resultFromIntent( string $type, PaymentIntent $paymentIntent, Price $amount, array $context, bool $authorizing, ): PaymentResult { $status = match ($paymentIntent->status) { PaymentIntent::STATUS_SUCCEEDED => PaymentResultStatus::Succeeded, PaymentIntent::STATUS_REQUIRES_CAPTURE => $authorizing ? PaymentResultStatus::Succeeded : PaymentResultStatus::Pending, PaymentIntent::STATUS_CANCELED => PaymentResultStatus::Failed, default => PaymentResultStatus::Pending, }; $continuation = $status === PaymentResultStatus::Pending ? new PaymentContinuation(PaymentContinuationType::ClientSecret, $paymentIntent->client_secret) : null; $result = new PaymentResult( status: $status, reference: $paymentIntent->id, amount: $amount, failureReason: $paymentIntent->last_payment_error->message ?? null, raw: $paymentIntent->toArray(), meta: $status === PaymentResultStatus::Pending ? [] : $this->cardMetaFromIntent($paymentIntent), continuation: $continuation, ); if ($status === PaymentResultStatus::Pending) { return $result; } $succeeded = $status === PaymentResultStatus::Succeeded; if ($authorizing) { $succeeded ? PaymentAuthorized::dispatch($type, $result, $context) : PaymentAuthorizationFailed::dispatch($type, $result, $context); } else { $succeeded ? PaymentCaptured::dispatch($type, $result, $context) : PaymentCaptureFailed::dispatch($type, $result, $context); } return $result; } /** * card_type/last_four for Modules\Core\Order\Services\ * TransactionRecorder to map onto Transaction (see PaymentResult:: * $meta's own docblock) — same fields, same source * (payment_method_details on the underlying Charge) as lunarphp/ * stripe's own StoreCharges, just reached via latest_charge instead of * an order-level charge list, since this driver has no Order/Cart to * enumerate charges from. * * @return array{card_type?: string, last_four?: string} */ private function cardMetaFromIntent(PaymentIntent $paymentIntent): array { $chargeId = $paymentIntent->latest_charge; if (blank($chargeId)) { return []; } $charge = $this->stripe->getCharge(is_string($chargeId) ? $chargeId : $chargeId->id); $paymentType = collect($charge->payment_method_details)->keys()->first(); $details = collect($charge->payment_method_details)->first(); if (blank($details)) { return []; } return array_filter([ 'card_type' => $details['brand'] ?? $paymentType, 'last_four' => $details['last4'] ?? null, ], fn ($value) => filled($value)); } private function declined(string $type, Price $amount, ApiErrorException $e, array $context, bool $authorizing): PaymentResult { $result = $this->failure($amount, $e); $authorizing ? PaymentAuthorizationFailed::dispatch($type, $result, $context) : PaymentCaptureFailed::dispatch($type, $result, $context); return $result; } private function failure(Price $amount, ApiErrorException $e, string $reference = ''): PaymentResult { $stripeError = $e->getError(); return new PaymentResult( status: PaymentResultStatus::Failed, reference: $reference ?: ($stripeError->payment_intent->id ?? ''), amount: $amount, failureReason: $e->getMessage(), retriable: in_array($stripeError->decline_code ?? null, [ 'do_not_honor', 'insufficient_funds', 'card_velocity_exceeded', 'processing_error', 'try_again_later', 'issuer_not_available', ], true), raw: $stripeError?->toArray() ?? [], ); } }