feature: implement payment gateway

- implement stripe checkout gateway
- add payment gateway factory and service
This commit is contained in:
kusowl 2026-03-23 17:29:24 +05:30
parent 0799965212
commit 2aa76db042
17 changed files with 486 additions and 0 deletions

View File

@ -0,0 +1,49 @@
<?php
namespace App\Actions;
use App\Data\PaymentResponseDTO;
use App\Enums\PaymentModes;
use App\Enums\PaymentStatusEnum;
use App\Models\Order;
use App\Models\PaymentStatus;
use App\Services\Payment\PaymentGatewayFactory;
use DB;
use Log;
use Throwable;
final readonly class ProcessOrderPaymentAction
{
public function __construct(private PaymentGatewayFactory $paymentGatewayFactory) {}
/**
* Execute the action.
*/
public function execute(Order $order, PaymentModes $mode): PaymentResponseDTO
{
$gateway = $this->paymentGatewayFactory->make($mode);
try {
DB::beginTransaction();
$response = $gateway->charge($order);
if ($response->isSuccess) {
$order->payments()->create(
[
'transaction_id' => $response->transactionId,
'amount' => $response->amount,
'currency' => $response->currency,
'payment_method' => $response->method->value,
'payment_status_id' => PaymentStatus::getIdByName(PaymentStatusEnum::Unpaid->value),
]
);
}
DB::commit();
return $response;
} catch (Throwable $e) {
DB::rollBack();
Log::error('Error occurred while processing the payment.', [$e->getMessage()]);
abort(500, 'Something went wrong. Please try again or contact us to get in touch with our support team. ');
}
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Contracts;
use App\Data\PaymentResponseDTO;
use App\Models\Order;
interface PaymentGateway
{
public function charge(Order $order): PaymentResponseDTO;
}

View File

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

View File

@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum PaymentModes: string
{
case StripeCheckout = 'stripeCheckout';
case CashOnDelivery = 'cashOnDelivery';
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum PaymentStatusEnum: string
{
case Unpaid = 'unpaid';
case Paid = 'paid';
case Refunded = 'refunded';
case Failed = 'failed';
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Actions\ProcessOrderPaymentAction;
use App\Enums\PaymentModes;
use App\Http\Requests\PaymentRequest;
use App\Http\Resources\PaymentResource;
use App\Models\Order;
use App\Models\Payment;
class PaymentController extends Controller
{
public function index()
{
return PaymentResource::collection(Payment::all());
}
public function store(PaymentRequest $request, Order $order, ProcessOrderPaymentAction $action)
{
$response = $action->execute($order, PaymentModes::tryFrom($request->mode));
return new PaymentResource($response);
}
public function show(Payment $payment)
{
return new PaymentResource($payment);
}
public function update(PaymentRequest $request, Payment $payment)
{
$payment->update($request->validated());
return new PaymentResource($payment);
}
public function destroy(Payment $payment)
{
$payment->delete();
return response()->json();
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests;
use App\Enums\PaymentModes;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PaymentRequest extends FormRequest
{
public function rules(): array
{
return [
'mode' => ['required', Rule::enum(PaymentModes::class)],
];
}
public function authorize(): bool
{
return true;
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Resources;
use App\Data\PaymentResponseDTO;
use App\Models\Payment;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Payment
*
* @property PaymentResponseDTO $resource
*/
class PaymentResource extends JsonResource
{
public static $wrap = null;
public function toArray(Request $request): array
{
return [
'success' => $this->resource->isSuccess,
'amount' => $this->resource->amount,
'currency' => $this->resource->currency,
'method' => $this->resource->method,
'redirectUrl' => $this->resource->redirectUrl,
'errorMessage' => $this->resource->errorMessage,
];
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @mixin IdeHelperPayment
*/
class Payment extends Model
{
protected $fillable = [
'order_id',
'transaction_id',
'amount',
'currency',
'payment_method',
'payment_status_id',
'error_message',
];
protected $with = ['paymentStatus'];
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function paymentStatus(): BelongsTo
{
return $this->belongsTo(PaymentStatus::class);
}
protected function status(): Attribute
{
return Attribute::make(fn () => $this->paymentStatus?->name);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* @mixin IdeHelperPaymentStatus
*/
class PaymentStatus extends Model
{
public $timestamps = false;
protected $fillable = [
'name',
];
public static function getIdByName(string $name): ?int
{
return static::where('name', $name)->value('id');
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Services\Payment;
use App\Contracts\PaymentGateway;
use App\Models\Order;
class CodPaymentGateway implements PaymentGateway
{
public function charge(Order $order)
{
// TODO: Implement charge() method
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Services\Payment;
use App\Enums\PaymentModes;
class PaymentGatewayFactory
{
public function make(PaymentModes $mode)
{
return match ($mode) {
PaymentModes::CashOnDelivery => new CodPaymentGateway,
PaymentModes::StripeCheckout => new StripePaymentGateway,
};
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Services\Payment;
use App\Contracts\PaymentGateway;
use App\Data\PaymentResponseDTO;
use App\Data\StripeLineItemDTO;
use App\Data\StripeSessionDataDTO;
use App\Enums\PaymentModes;
use App\Enums\StripeCurrency;
use App\Enums\StripePaymentMode;
use App\Models\Order;
use Exception;
use Illuminate\Support\Facades\Log;
use Stripe\Checkout\Session;
use Stripe\Stripe;
class StripePaymentGateway implements PaymentGateway
{
public function __construct()
{
Stripe::setApiKey(config('services.stripe.secret'));
}
public function charge(Order $order): PaymentResponseDTO
{
$cart = $order->cart()->withProducts()->first();
$totalAmount = 0;
$lineItems = [];
foreach ($cart->products as $product) {
$priceInCents = (int) ($product->pivot->price * 100);
$totalAmount += ($priceInCents * $product->pivot->quantity);
$lineItems[] = new StripeLineItemDTO(
currency: StripeCurrency::INR,
price: $priceInCents,
productName: $product->title,
productDescription: $product->description,
quantity: $product->pivot->quantity,
);
}
$currency = StripeCurrency::INR->value;
try {
$data = new StripeSessionDataDTO(
lineItems: $lineItems,
mode: StripePaymentMode::Payment,
successUrl: config('app.frontend_url').'/order/payment/success?session_id={CHECKOUT_SESSION_ID}"',
cancelUrl: config('app.frontend_url').'/order/payment/cancel',
);
$session = Session::create($data->toArray());
// add stripe session to order
$order->stripeSession()->create(
[
'session_id' => $session->id,
]
);
return PaymentResponseDTO::success(
transactionId: $session->id,
amount: $totalAmount,
currency: $currency,
method: PaymentModes::StripeCheckout,
redirectUrl: $session->url,
);
} catch (Exception $e) {
Log::error('Stripe Checkout session cannot be created.', ['error' => $e->getMessage()]);
return PaymentResponseDTO::failure(
amount: $totalAmount,
currency: $currency,
method: PaymentModes::StripeCheckout,
errorMessage: 'Payment gateway unavailable. Please try again.'
);
}
}
}

View File

@ -0,0 +1,29 @@
<?php
use App\Models\PaymentStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id');
$table->string('transaction_id');
$table->integer('amount');
$table->string('currency');
$table->string('payment_method');
$table->foreignIdFor(PaymentStatus::class);
$table->timestamps();
$table->unique(['transaction_id']);
});
}
public function down(): void
{
Schema::dropIfExists('payments');
}
};

View File

@ -0,0 +1,21 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payment_statuses', function (Blueprint $table) {
$table->id();
$table->string('name');
});
}
public function down(): void
{
Schema::dropIfExists('payment_statuses');
}
};

View File

@ -0,0 +1,20 @@
<?php
namespace Database\Seeders;
use App\Enums\PaymentStatusEnum;
use App\Models\PaymentStatus;
use Illuminate\Database\Seeder;
class PaymentStatusSeeder extends Seeder
{
public function run(): void
{
$data = array_map(function (PaymentStatusEnum $status) {
return [
'name' => $status->value,
];
}, PaymentStatusEnum::cases());
PaymentStatus::insertOrIgnore($data);
}
}

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\CartController;
use App\Http\Controllers\FavouriteProductController;
use App\Http\Controllers\OrderController;
use App\Http\Controllers\PaymentController;
use App\Http\Controllers\ProductCategoryController;
use App\Http\Controllers\ProductController;
use App\Http\Controllers\ProductImagesController;
@ -29,6 +30,7 @@
Route::apiResource('user.addresses', UserAddressController::class)->shallow();
Route::apiResource('users.orders', OrderController::class)->shallow();
Route::post('orders/{order}/payments', [PaymentController::class, 'store']);
});
Route::get('/categories', [ProductCategoryController::class, 'index']);
Route::apiResource('products', ProductController::class);