Feature: Payment resolver, Payment Provider, Completing Stripe Webhooks, Wiring Payments to checkout service
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\DTOs;
|
||||
|
||||
use Modules\Core\Payment\Enums\PaymentContinuationType;
|
||||
|
||||
/**
|
||||
* What a caller does next with a Pending PaymentResult, gateway-agnostic —
|
||||
* see PaymentContinuationType for the two shapes. Deliberately minimal:
|
||||
* this is NOT a return to the deleted PaymentInitiation DTO (which also
|
||||
* carried mode/reference/meta) — reference already lives on PaymentResult
|
||||
* itself, and mode is now this DTO's own $type.
|
||||
*/
|
||||
final class PaymentContinuation
|
||||
{
|
||||
public function __construct(
|
||||
public readonly PaymentContinuationType $type,
|
||||
public readonly string $value,
|
||||
) {}
|
||||
}
|
||||
@@ -47,6 +47,11 @@ final class PaymentResult
|
||||
* normalize into anything above.
|
||||
* @param $meta driver-specific extras that don't fit the normalized
|
||||
* fields above (e.g. a card's last four digits).
|
||||
* @param $continuation only meaningful when $status is Pending —
|
||||
* what the caller does next (a redirect URL, a client secret for
|
||||
* frontend JS), gateway-agnostic. Null for every other status, and
|
||||
* for any driver whose pay()/authorize() never returns Pending
|
||||
* (e.g. OfflinePaymentDriver).
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly PaymentResultStatus $status,
|
||||
@@ -56,5 +61,6 @@ final class PaymentResult
|
||||
public readonly bool $retriable = false,
|
||||
public readonly array $raw = [],
|
||||
public readonly array $meta = [],
|
||||
public readonly ?PaymentContinuation $continuation = null,
|
||||
) {}
|
||||
}
|
||||
@@ -14,7 +14,9 @@ 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;
|
||||
@@ -319,12 +321,17 @@ class StripePaymentDriver implements
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Enums;
|
||||
|
||||
/**
|
||||
* What a caller of a Pending PaymentResult needs to do next, gateway-
|
||||
* agnostically. Only meaningful when PaymentResult::$continuation is not
|
||||
* null (status === Pending).
|
||||
*/
|
||||
enum PaymentContinuationType
|
||||
{
|
||||
/** Send the shopper to $continuation->value (a URL) — a redirect-based gateway. */
|
||||
case Redirect;
|
||||
|
||||
/** Hand $continuation->value (a client secret) to frontend JS — Stripe Elements-style. */
|
||||
case ClientSecret;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Thrown by a Modules\Core\Payment\Contracts\PaymentDriver when the
|
||||
* gateway has not confirmed payment — wrong/expired intent, already
|
||||
* processed, or the gateway itself rejects the confirmation. A driver
|
||||
* throws this instead of silently placing the order: CheckoutService::
|
||||
* placeOrder() must only ever be called once a driver has positively
|
||||
* confirmed payment, never as a fallback.
|
||||
*/
|
||||
class PaymentNotConfirmedException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, previous: $previous);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Payment\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\Core\Payment\Drivers\StripePaymentDriver;
|
||||
use Stripe\Webhook;
|
||||
|
||||
/**
|
||||
* A boboko-owned webhook endpoint for Stripe — deliberately NOT
|
||||
* lunarphp/stripe's own route (vendor/lunarphp/stripe/routes/webhooks.php),
|
||||
* which dispatches into Lunar's own Payments::driver('stripe') flow (the
|
||||
* flow StripePaymentDriver was built to replace, see that class's own
|
||||
* docblock). Signature verification is handled by
|
||||
* Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware, registered on this
|
||||
* route (see src/Payment/routes/webhooks.php) — pure Stripe SDK
|
||||
* verification + event-type filtering, safe to reuse even though this
|
||||
* controller never touches the rest of that vendor package's flow. This
|
||||
* controller verifies the signature again itself (Webhook::constructEvent())
|
||||
* to get the constructed Event object — the middleware doesn't stash one
|
||||
* anywhere reusable, it only gates the request through.
|
||||
*
|
||||
* Resolves the driver directly by class, not via
|
||||
* Modules\Core\Payment\Services\PaymentDriverResolver — this endpoint is
|
||||
* inherently Stripe-specific (Stripe's own webhook payload carries no
|
||||
* boboko payment-type key, only its own payment_intent id), and
|
||||
* StripePaymentDriver::handleCallback() already recovers $type itself
|
||||
* from the StripePaymentIntent row pay()/authorize() wrote.
|
||||
*/
|
||||
class StripeWebhookController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, StripePaymentDriver $driver): JsonResponse
|
||||
{
|
||||
$event = Webhook::constructEvent(
|
||||
$request->getContent(),
|
||||
$request->header('Stripe-Signature'),
|
||||
config('services.stripe.webhooks.lunar'),
|
||||
);
|
||||
|
||||
$driver->handleCallback($event->data->object->id, $event->data->object->toArray());
|
||||
|
||||
return response()->json(['webhook_successful' => true]);
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,19 @@
|
||||
|
||||
namespace Modules\Core\Payment\Services;
|
||||
|
||||
use Modules\Core\Payment\Contracts\PaymentDriver;
|
||||
|
||||
/**
|
||||
* Resolves a payment type key (e.g. 'stripe', 'cash-on-delivery') to its
|
||||
* registered PaymentDriver — extracted out of CheckoutService so both it
|
||||
* and anything else needing the same lookup (e.g. a listener reacting to
|
||||
* OrderPlaced, which has no reason to depend on Checkout's own service)
|
||||
* share one implementation instead of duplicating this config read.
|
||||
* registered driver instance — extracted out of CheckoutService so both it
|
||||
* and anything else needing the same lookup share one implementation
|
||||
* instead of duplicating this config read.
|
||||
*
|
||||
* Returns a plain object, not a shared interface — Payment's own drivers
|
||||
* implement several independent, orthogonal capability interfaces at once
|
||||
* (Configurable, SupportsPay, SupportsAuthorization, ...; see
|
||||
* StripePaymentDriver implementing all six). There is no single common
|
||||
* "PaymentDriver" contract to type this against; a caller checks
|
||||
* `instanceof SupportsPay` / `instanceof SupportsAuthorization` itself,
|
||||
* the same way Payment's own contracts are designed to be consumed.
|
||||
*/
|
||||
class PaymentDriverResolver
|
||||
{
|
||||
@@ -20,7 +25,7 @@ class PaymentDriverResolver
|
||||
* can filter unresolvable types silently rather than treating "not
|
||||
* registered" as an error condition when just checking availability.
|
||||
*/
|
||||
public function resolve(string $type): ?PaymentDriver
|
||||
public function resolve(string $type): ?object
|
||||
{
|
||||
$driverClass = config("lunar.payments.types.{$type}.payment_driver");
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware;
|
||||
use Modules\Core\Payment\Http\Controllers\StripeWebhookController;
|
||||
|
||||
Route::post(
|
||||
config('payment.stripe.webhook_path', 'payments/stripe/webhook'),
|
||||
StripeWebhookController::class
|
||||
)
|
||||
->middleware([StripeWebhookMiddleware::class, 'api'])
|
||||
->withoutMiddleware([VerifyCsrfToken::class])
|
||||
->name('payment.stripe.webhook');
|
||||
Reference in New Issue
Block a user