Feat: Payment Restructuring to be fully event-driven
This commit is contained in:
@@ -2,44 +2,67 @@
|
||||
|
||||
namespace Modules\Core\Payment\Drivers;
|
||||
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Stripe\Actions\UpdateOrderFromIntent;
|
||||
use Lunar\DataTypes\Price;
|
||||
use Lunar\Models\Currency;
|
||||
use Lunar\Stripe\Facades\Stripe;
|
||||
use Lunar\Stripe\Managers\StripeManager;
|
||||
use Lunar\Stripe\Models\StripePaymentIntent;
|
||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||
use Modules\Core\Checkout\Events\PaymentConfirmed;
|
||||
use Modules\Core\Payment\Contracts\PaymentDriver;
|
||||
use Modules\Core\Payment\Exceptions\PaymentNotConfirmedException;
|
||||
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\PaymentResult;
|
||||
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 Stripe\Exception\ApiErrorException;
|
||||
use Stripe\PaymentIntent;
|
||||
|
||||
/**
|
||||
* Wraps Lunar\Stripe\StripePaymentType::authorize() to satisfy
|
||||
* Modules\Core\Payment\Contracts\PaymentDriver — dispatches
|
||||
* PaymentConfirmed at the moment Stripe confirms payment, instead of the
|
||||
* vendor's own Cart::createOrder() call.
|
||||
* Talks to Stripe's PaymentIntent API directly — deliberately NOT via
|
||||
* Lunar\Stripe\Facades\Stripe::createIntent()/fetchOrCreateIntent(), which
|
||||
* take a Lunar\Models\Cart and derive 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.
|
||||
*
|
||||
* This is a fork, not a decoration: StripePaymentType::authorize() is
|
||||
* `final` and calls Cart::createOrder() directly with no seam to redirect
|
||||
* that one call — so this class reimplements authorize()'s logic (intent
|
||||
* retrieval, capture-on-policy) rather than wrapping the vendor method.
|
||||
* Kept deliberately close to the original so a lunarphp/stripe upgrade is
|
||||
* easy to diff against. See docs/payments.md.
|
||||
* 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.
|
||||
*
|
||||
* The status-mapping step (UpdateOrderFromIntent) this driver used to do
|
||||
* inline right after placeOrder() returned now happens in onOrderPlaced()
|
||||
* below instead — see PaymentDriver's docblock for why a driver can no
|
||||
* longer rely on placeOrder()'s return value. Since that step needs the
|
||||
* live Stripe PaymentIntent, not just the Order, onOrderPlaced() re-fetches
|
||||
* it from Stripe via the StripePaymentIntent row this method already wrote
|
||||
* (keyed by the order's cart_id) rather than carrying the PaymentIntent
|
||||
* object across the event boundary itself.
|
||||
* 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 PaymentDriver
|
||||
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')) in
|
||||
* StripeManager::__construct()) — no key, no usable driver.
|
||||
* (Stripe::setApiKey(config('services.stripe.key'))) — no key, no
|
||||
* usable driver.
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
@@ -47,79 +70,307 @@ class StripePaymentDriver implements PaymentDriver
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws PaymentNotConfirmedException if Stripe hasn't confirmed the
|
||||
* payment intent (wrong intent id, already processed, or the gateway
|
||||
* call itself fails) — nothing here should be treated as "confirm
|
||||
* anyway."
|
||||
* 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 confirm(Cart $cart, string $type, string $fingerprint, array $data): void
|
||||
public function pay(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
|
||||
{
|
||||
$paymentIntentId = $data['payment_intent'];
|
||||
|
||||
$paymentIntentModel = StripePaymentIntent::where('intent_id', $paymentIntentId)->first();
|
||||
|
||||
if ($paymentIntentModel && ! $paymentIntentModel->isActive()) {
|
||||
throw new PaymentNotConfirmedException('Payment intent already processed.');
|
||||
}
|
||||
|
||||
if (! $paymentIntentModel) {
|
||||
$paymentIntentModel = StripePaymentIntent::create([
|
||||
'intent_id' => $paymentIntentId,
|
||||
'cart_id' => $cart->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$paymentIntentModel->update(['processing_at' => now()]);
|
||||
|
||||
$stripe = Stripe::getClient();
|
||||
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
|
||||
|
||||
if (! $paymentIntent) {
|
||||
throw new PaymentNotConfirmedException('Unable to locate payment intent.');
|
||||
}
|
||||
|
||||
$policy = config('lunar.stripe.policy', 'automatic');
|
||||
|
||||
if ($paymentIntent->status === PaymentIntent::STATUS_REQUIRES_CAPTURE && $policy === 'automatic') {
|
||||
$paymentIntent = $stripe->paymentIntents->capture($paymentIntentId);
|
||||
}
|
||||
|
||||
if ($paymentIntent->status !== PaymentIntent::STATUS_SUCCEEDED) {
|
||||
$paymentIntentModel->update(['status' => $paymentIntent->status]);
|
||||
|
||||
throw new PaymentNotConfirmedException(
|
||||
$paymentIntent->last_payment_error->message ?? "Payment intent status: {$paymentIntent->status}."
|
||||
);
|
||||
}
|
||||
|
||||
$paymentIntentModel->status = $paymentIntent->status;
|
||||
$paymentIntentModel->save();
|
||||
|
||||
PaymentConfirmed::dispatch($cart, $type, $fingerprint, $data);
|
||||
return $this->createAndConfirm($type, $amount, $data, $context, captureMethod: 'automatic');
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered in PaymentServiceProvider. Matches via the order's
|
||||
* cart_id against the StripePaymentIntent row confirm() wrote, so a
|
||||
* non-Stripe OrderPlaced (offline types fire the same event) is
|
||||
* ignored rather than acted on.
|
||||
* 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 onOrderPlaced(OrderPlaced $event): void
|
||||
public function authorize(string $type, Price $amount, array $data = [], array $context = []): PaymentResult
|
||||
{
|
||||
$order = $event->order;
|
||||
return $this->createAndConfirm($type, $amount, $data, $context, captureMethod: 'manual');
|
||||
}
|
||||
|
||||
$paymentIntentModel = StripePaymentIntent::where('cart_id', $order->cart_id)->first();
|
||||
|
||||
if (! $paymentIntentModel) {
|
||||
return;
|
||||
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');
|
||||
}
|
||||
|
||||
$paymentIntentModel->order_id = $order->id;
|
||||
$paymentIntentModel->processed_at = now();
|
||||
$paymentIntentModel->save();
|
||||
$this->rememberIntent($paymentIntent, $type, $context);
|
||||
|
||||
$paymentIntent = Stripe::getClient()->paymentIntents->retrieve($paymentIntentModel->intent_id);
|
||||
return $this->resultFromIntent($type, $paymentIntent, $amount, $context, authorizing: $captureMethod === 'manual');
|
||||
}
|
||||
|
||||
UpdateOrderFromIntent::execute($order, $paymentIntent);
|
||||
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<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,
|
||||
$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<string, mixed>|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,
|
||||
};
|
||||
|
||||
$result = new PaymentResult(
|
||||
status: $status,
|
||||
reference: $paymentIntent->id,
|
||||
amount: $amount,
|
||||
failureReason: $paymentIntent->last_payment_error->message ?? null,
|
||||
raw: $paymentIntent->toArray(),
|
||||
);
|
||||
|
||||
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() ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user