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 the same * way lunarphp/stripe's own StripePaymentType/ProcessStripeWebhook solve * it: real cart_id/order_id columns on Lunar\Stripe\Models\ * StripePaymentIntent (a table already owned by lunarphp/stripe, 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 { /** * Same key lunarphp/stripe's own StripeManager reads its API key from * (Stripe::setApiKey(config('services.stripe.key'))) — 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 { try { $paymentIntent = Stripe::getClient()->paymentIntents->create([ 'amount' => StripeManager::toStripeAmount($amount->value, $amount->currency), 'currency' => $amount->currency->code, 'capture_method' => $captureMethod, 'confirm' => true, 'payment_method' => $data['payment_method'] ?? null, 'automatic_payment_methods' => isset($data['payment_method']) ? null : ['enabled' => true], ]); } 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 = 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 = 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 = 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(), ); $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 = 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 = 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' => json_encode($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, $this->decodeContext($intentModel) ?? $context, ]; } /** * StripePaymentIntent is a vendor model (lunarphp/stripe) with no cast * declared for our own 'context' column (added by boboko-core's own * migration, see database/migrations/..._add_context_to_stripe_ * payment_intents.php) — we can't edit the vendor model to add one, so * decode manually here instead of assuming Eloquent already did it. * * @return array|null */ private function decodeContext(?StripePaymentIntent $intentModel): ?array { if (! $intentModel || ! $intentModel->context) { return null; } return json_decode($intentModel->context, associative: true) ?: null; } /** * 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(), 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; } 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() ?? [], ); } }