feature: implement payment verification, make cart as converted after successful payment

This commit is contained in:
kusowl 2026-03-25 15:24:40 +05:30
parent 8e1fe1336e
commit ca0aaaa84b
11 changed files with 199 additions and 19 deletions

View File

@ -0,0 +1,20 @@
<?php
namespace App\Actions;
use App\Enums\CartStatus;
use App\Models\Cart;
final readonly class MarkCartAsConvertedAction
{
/**
* Execute the action.
*/
public function execute(Cart $cart): Cart
{
$cart->status = CartStatus::Converted;
$cart->save();
return $cart;
}
}

View File

@ -5,23 +5,45 @@
use App\Enums\PaymentStatusEnum; use App\Enums\PaymentStatusEnum;
use App\Models\Payment; use App\Models\Payment;
use App\Models\PaymentStatus; use App\Models\PaymentStatus;
use DB;
use Log;
use Symfony\Component\Translation\Exception\NotFoundResourceException; use Symfony\Component\Translation\Exception\NotFoundResourceException;
use Throwable;
final readonly class MarkPaymentAsPaidAction final readonly class MarkPaymentAsPaidAction
{ {
public function __construct(private MarkCartAsConvertedAction $cartAsConvertedAction) {}
/** /**
* Execute the action. * Execute the action.
* *
* @throws NotFoundResourceException * @throws NotFoundResourceException|Throwable
*/ */
public function execute(Payment $payment): bool public function execute(Payment $payment): bool
{ {
try {
DB::beginTransaction();
// get the cart and make the status to converted
$cart = $payment->order->cart;
$this->cartAsConvertedAction->execute($cart);
$status = PaymentStatus::whereName(PaymentStatusEnum::Paid->value)->value('id'); $status = PaymentStatus::whereName(PaymentStatusEnum::Paid->value)->value('id');
if (! $status) { if (! $status) {
throw new NotFoundResourceException('Paid Status not found'); throw new NotFoundResourceException('Paid Status not found');
} }
$payment->payment_status_id = $status; $payment->payment_status_id = $status;
return $payment->save(); $payment->save();
DB::commit();
return true;
} catch (Throwable $e) {
Log::error('Cannot mark order payment as paid', [$e->getMessage()]);
DB::rollBack();
return false;
}
} }
} }

View File

@ -14,7 +14,9 @@
final readonly class ProcessOrderPaymentAction final readonly class ProcessOrderPaymentAction
{ {
public function __construct(private PaymentGatewayFactory $paymentGatewayFactory) {} public function __construct(
private PaymentGatewayFactory $paymentGatewayFactory,
) {}
/** /**
* Execute the action. * Execute the action.

View File

@ -0,0 +1,57 @@
<?php
namespace App\Actions;
use App\Data\VerifiedCheckoutResponseDTO;
use App\Models\User;
use Exception;
use Log;
use Stripe\Exception\ApiErrorException;
use Stripe\StripeClient;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
final readonly class VerifyStripeSessionAction
{
public function __construct(private StripeClient $stripe) {}
/**
* Execute the action.
*/
public function execute(int $orderId, string $stripeSessionId, User $user): VerifiedCheckoutResponseDTO
{
/**
* Check if the order is actually made by user
*/
$order = $user->orders()
->whereId($orderId)
->first();
if (! $order) {
throw new AccessDeniedHttpException('Order is not made by you');
}
if (! $order->stripeSession()->where('session_id', $stripeSessionId)->exists()) {
throw new AccessDeniedHttpException('Stripe session is not made by you');
}
try {
$session = $this->stripe->checkout->sessions->retrieve($stripeSessionId);
} catch (ApiErrorException $e) {
Log::error('Stripe api is not available: ', [$e->getMessage()]);
throw new ServiceUnavailableHttpException('Stripe api is not available');
} catch (Exception $e) {
throw new NotFoundHttpException('Invalid Stripe session id');
}
if ($session->payment_status !== 'paid' || $session->status !== 'complete') {
return VerifiedCheckoutResponseDTO::failure('Payment Unsuccessful');
}
return VerifiedCheckoutResponseDTO::success(
message: 'Payment Successful',
amount: $session->amount_total,
transactionId: $session->payment_intent,
mode: $session->payment_method_types[0],
);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Data;
use App\Contracts\OutputDataTransferObject;
final readonly class VerifiedCheckoutResponseDTO implements OutputDataTransferObject
{
public function __construct(
public bool $isSuccess,
public string $message,
public ?int $amount = null,
public ?string $transactionId = null,
public ?string $mode = null
) {}
public static function failure(string $message): VerifiedCheckoutResponseDTO
{
return new self(isSuccess: false, message: $message);
}
public static function success(string $message, int $amount, string $transactionId, string $mode): VerifiedCheckoutResponseDTO
{
return new self(isSuccess: true, message: $message, amount: $amount, transactionId: $transactionId,
mode: $mode);
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'isSuccess' => $this->isSuccess,
'message' => $this->message,
'amount' => $this->amount,
'transactionId' => $this->transactionId,
'paymentMethod' => $this->mode,
];
}
}

View File

@ -3,8 +3,10 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Actions\ProcessOrderPaymentAction; use App\Actions\ProcessOrderPaymentAction;
use App\Actions\VerifyStripeSessionAction;
use App\Enums\PaymentModes; use App\Enums\PaymentModes;
use App\Http\Requests\PaymentRequest; use App\Http\Requests\PaymentRequest;
use App\Http\Requests\VerifyPaymentRequest;
use App\Http\Resources\PaymentResource; use App\Http\Resources\PaymentResource;
use App\Models\Order; use App\Models\Order;
use App\Models\Payment; use App\Models\Payment;
@ -23,6 +25,11 @@ public function store(PaymentRequest $request, Order $order, ProcessOrderPayment
return new PaymentResource($response); return new PaymentResource($response);
} }
public function verify(VerifyPaymentRequest $request, VerifyStripeSessionAction $action)
{
return $action->execute($request->orderId, $request->sessionId, $request->user())->toArray();
}
public function show(Payment $payment) public function show(Payment $payment)
{ {
return new PaymentResource($payment); return new PaymentResource($payment);
@ -34,11 +41,4 @@ public function update(PaymentRequest $request, Payment $payment)
return new PaymentResource($payment); return new PaymentResource($payment);
} }
public function destroy(Payment $payment)
{
$payment->delete();
return response()->json();
}
} }

View File

@ -15,9 +15,7 @@
class StripeWebhookController extends Controller class StripeWebhookController extends Controller
{ {
public function __construct(private readonly MarkPaymentAsPaidAction $paidAction) public function __construct(private readonly MarkPaymentAsPaidAction $paidAction) {}
{
}
public function __invoke(Request $request) public function __invoke(Request $request)
{ {

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class VerifyPaymentRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'sessionId' => 'required|exists:stripe_sessions,session_id',
'orderId' => 'required|exists:orders,id',
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@ -44,4 +45,11 @@ public function payments(): HasMany
{ {
return $this->hasMany(Payment::class); return $this->hasMany(Payment::class);
} }
protected function totalAmount(): Attribute
{
return Attribute::make(
fn () => $this->cart?->products->sum(fn ($product) => $product->pivot->price * $product->pivot->quantity) ?? 0
);
}
} }

View File

@ -3,6 +3,7 @@
namespace App\Providers; namespace App\Providers;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Stripe\StripeClient;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -11,7 +12,7 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
// $this->app->singleton(StripeClient::class, fn () => new StripeClient(config('services.stripe.secret')));
} }
/** /**

View File

@ -32,6 +32,7 @@
Route::apiResource('user.addresses', UserAddressController::class)->shallow(); Route::apiResource('user.addresses', UserAddressController::class)->shallow();
Route::apiResource('users.orders', OrderController::class)->shallow(); Route::apiResource('users.orders', OrderController::class)->shallow();
Route::post('orders/{order}/payments', [PaymentController::class, 'store']); Route::post('orders/{order}/payments', [PaymentController::class, 'store']);
Route::post('payments/verify', [PaymentController::class, 'verify']);
}); });
Route::get('/categories', [ProductCategoryController::class, 'index']); Route::get('/categories', [ProductCategoryController::class, 'index']);
Route::apiResource('products', ProductController::class); Route::apiResource('products', ProductController::class);