Feature: Payment resolver, Payment Provider, Completing Stripe Webhooks, Wiring Payments to checkout service

This commit is contained in:
2026-09-03 17:27:34 +03:00
parent 35c3334690
commit 456943dc74
14 changed files with 266 additions and 126 deletions
+20
View File
@@ -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,
) {}
}
+6
View File
@@ -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]);
}
}
+12 -7
View File
@@ -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");
+14
View File
@@ -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');