feat(checkout): add checkout controller with Stripe flow
This commit is contained in:
1 parent
63768c66d5
commit
1346766a73
1 file changed
+153
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bazaar\Controllers;
|
||||||
|
|
||||||
|
use Bazaar\Core\Config;
|
||||||
|
use Bazaar\Core\Response;
|
||||||
|
use Bazaar\Services\InsufficientStockException;
|
||||||
|
use Bazaar\Services\StripeGateway;
|
||||||
|
|
||||||
|
final class CheckoutController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): Response
|
||||||
|
{
|
||||||
|
$subtotalCents = $this->ctx->cart->totalCents();
|
||||||
|
$discount = $this->resolveDiscount($subtotalCents);
|
||||||
|
|
||||||
|
return $this->view('checkout.index', [
|
||||||
|
'lines' => $this->ctx->cart->lines(),
|
||||||
|
'subtotalCents' => $subtotalCents,
|
||||||
|
'discountCents' => $discount['discountCents'],
|
||||||
|
'couponCode' => $discount['couponCode'],
|
||||||
|
'totalCents' => $subtotalCents - $discount['discountCents'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
$cart = $this->ctx->cart;
|
||||||
|
if ($cart->isEmpty()) {
|
||||||
|
return $this->redirect('/cart');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Config::stripeConfigured()) {
|
||||||
|
$this->ctx->session->flash('error', 'Stripe is not configured yet. Add test keys in .env to accept payments.');
|
||||||
|
return $this->redirect('/checkout');
|
||||||
|
}
|
||||||
|
|
||||||
|
$currency = (string) Config::get('CURRENCY', 'usd');
|
||||||
|
$discount = $this->resolveDiscount($cart->totalCents());
|
||||||
|
try {
|
||||||
|
$orderId = $this->ctx->orderService->createFromCart(
|
||||||
|
$this->ctx->auth->id(),
|
||||||
|
$cart,
|
||||||
|
$currency,
|
||||||
|
$discount['discountCents'] > 0 ? $discount : null
|
||||||
|
);
|
||||||
|
} catch (InsufficientStockException $error) {
|
||||||
|
$this->ctx->session->flash('error', $error->getMessage());
|
||||||
|
return $this->redirect('/cart');
|
||||||
|
}
|
||||||
|
|
||||||
|
$appUrl = rtrim((string) Config::get('APP_URL'), '/');
|
||||||
|
try {
|
||||||
|
$session = $this->gateway()->createCheckoutSession(
|
||||||
|
$orderId,
|
||||||
|
$cart->lines(),
|
||||||
|
$appUrl . '/checkout/success?session_id={CHECKOUT_SESSION_ID}',
|
||||||
|
$appUrl . '/checkout/cancel'
|
||||||
|
);
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
$this->ctx->session->flash('error', 'Could not reach the payment provider. Please try again.');
|
||||||
|
return $this->redirect('/checkout');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ctx->orderService->attachStripeSession($orderId, $session['id']);
|
||||||
|
return $this->redirect($session['url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function success(): Response
|
||||||
|
{
|
||||||
|
$sessionId = (string) $this->ctx->request->query('session_id', '');
|
||||||
|
if ($sessionId === '' || !Config::stripeConfigured()) {
|
||||||
|
return $this->redirect('/account/orders');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$details = $this->gateway()->retrieveSession($sessionId);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return $this->redirect('/account/orders');
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderId = $details['orderId'];
|
||||||
|
$order = $orderId !== null
|
||||||
|
? $this->ctx->orders->findForUser($orderId, $this->ctx->auth->id())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($order !== null && $details['paymentStatus'] === 'paid') {
|
||||||
|
$this->ctx->orderService->markPaid($orderId, $sessionId);
|
||||||
|
$this->ctx->cart->clear();
|
||||||
|
$order = $this->ctx->orders->find($orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->view('checkout.success', [
|
||||||
|
'order' => $order,
|
||||||
|
'paymentStatus' => $details['paymentStatus'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(): Response
|
||||||
|
{
|
||||||
|
return $this->view('checkout.cancel', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function webhook(): Response
|
||||||
|
{
|
||||||
|
if (!Config::stripeConfigured()) {
|
||||||
|
return Response::json(['ignored' => true], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->ctx->request->rawBody();
|
||||||
|
$signature = (string) $this->ctx->request->header('Stripe-Signature');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$event = $this->gateway()->parseWebhook($payload, $signature);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return Response::json(['error' => 'invalid signature'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($event['type'] === 'checkout.session.completed') {
|
||||||
|
$session = $event['session'];
|
||||||
|
$orderId = isset($session->metadata->orderId)
|
||||||
|
? (int) $session->metadata->orderId
|
||||||
|
: (int) $session->client_reference_id;
|
||||||
|
if ($orderId > 0) {
|
||||||
|
$this->ctx->orderService->markPaid($orderId, $session->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::json(['received' => true], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveDiscount(int $subtotalCents): array
|
||||||
|
{
|
||||||
|
$couponCode = (string) $this->ctx->session->get('couponCode', '');
|
||||||
|
if ($couponCode !== '') {
|
||||||
|
$result = $this->ctx->couponService->evaluate($couponCode, $subtotalCents);
|
||||||
|
if ($result->isValid()) {
|
||||||
|
return ['couponCode' => $result->coupon['code'], 'discountCents' => $result->discountCents];
|
||||||
|
}
|
||||||
|
$this->ctx->session->forget('couponCode');
|
||||||
|
}
|
||||||
|
return ['couponCode' => '', 'discountCents' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function gateway(): StripeGateway
|
||||||
|
{
|
||||||
|
return new StripeGateway(
|
||||||
|
(string) Config::get('STRIPE_SECRET_KEY'),
|
||||||
|
(string) Config::get('STRIPE_WEBHOOK_SECRET'),
|
||||||
|
(string) Config::get('CURRENCY', 'usd')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user