413 lines
16 KiB
PHP
413 lines
16 KiB
PHP
<?php
|
|
|
|
namespace Modules\Core\Payment\Drivers;
|
|
|
|
use Lunar\DataTypes\Price;
|
|
use Lunar\Models\Currency;
|
|
use Modules\Core\Payment\Contracts\Configurable;
|
|
use Modules\Core\Payment\Contracts\HandlesPaymentCallback;
|
|
use Modules\Core\Payment\Contracts\SupportsAuthorization;
|
|
use Modules\Core\Payment\Contracts\SupportsCaptures;
|
|
use Modules\Core\Payment\Contracts\SupportsPay;
|
|
use Modules\Core\Payment\Contracts\SupportsRefunds;
|
|
use Modules\Core\Payment\Contracts\SupportsVoids;
|
|
use Modules\Core\Payment\DTOs\PaymentContinuation;
|
|
use Modules\Core\Payment\DTOs\PaymentResult;
|
|
use Modules\Core\Payment\Enums\PaymentContinuationType;
|
|
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
|
use Modules\Core\Payment\Events\PaymentAuthorizationFailed;
|
|
use Modules\Core\Payment\Events\PaymentAuthorized;
|
|
use Modules\Core\Payment\Events\PaymentCaptureFailed;
|
|
use Modules\Core\Payment\Events\PaymentCaptured;
|
|
use Modules\Core\Payment\Events\PaymentRefundFailed;
|
|
use Modules\Core\Payment\Events\PaymentRefunded;
|
|
use Modules\Core\Payment\Events\PaymentVoidFailed;
|
|
use Modules\Core\Payment\Events\PaymentVoided;
|
|
use Modules\Core\Payment\Models\StripePaymentIntent;
|
|
use Modules\Core\Payment\Support\StripeManager;
|
|
use Stripe\Exception\ApiErrorException;
|
|
use Stripe\PaymentIntent;
|
|
|
|
/**
|
|
* Talks to Stripe's PaymentIntent API directly — deliberately NOT via
|
|
* Lunar's own checkout flow (lunarphp/stripe, since removed — see
|
|
* Modules\Core\Payment\Support\StripeManager's own docblock), which took a
|
|
* Lunar\Models\Cart and derived amount/currency from it. Payment must
|
|
* never receive a Cart (see docs/payments.md) — pay()/authorize() already
|
|
* receive $amount explicitly as their own required Lunar Price parameter
|
|
* (see PaymentResult's own docblock), the caller's job to assemble, same
|
|
* as every other driver.
|
|
*
|
|
* Every amount that crosses this class's own boundary is converted right
|
|
* there: Lunar's Price -> 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<string, mixed>}
|
|
*/
|
|
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() ?? [],
|
|
);
|
|
}
|
|
}
|