Skip to content

Commit 224a37f

Browse files
committed
feat: Implement checkout process with Stripe integration
- Added payment_intent_id field to Cart model. - Updated composer.json to include stripe/stripe-php package. - Created CheckoutController to handle checkout initiation, payment processing, and webhook handling. - Added CheckoutRequest for validating checkout requests. - Implemented StripeService for managing Stripe payment intents and webhooks. - Created tests for checkout API, payment processing, and webhook handling. - Ensured proper error handling and validation for checkout operations.
1 parent 56e2094 commit 224a37f

File tree

7 files changed

+1353
-1
lines changed

7 files changed

+1353
-1
lines changed
Lines changed: 393 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,393 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Models\Cart;
7+
use App\Services\StripeService;
8+
use Illuminate\Http\Request;
9+
use Illuminate\Http\JsonResponse;
10+
use Illuminate\Support\Facades\DB;
11+
use Illuminate\Support\Facades\Log;
12+
13+
class CheckoutController extends Controller
14+
{
15+
private StripeService $stripeService;
16+
17+
public function __construct(StripeService $stripeService)
18+
{
19+
$this->stripeService = $stripeService;
20+
}
21+
22+
/**
23+
* Initiate checkout process
24+
*/
25+
public function initiate(Request $request): JsonResponse
26+
{
27+
try {
28+
$validated = $request->validate([
29+
'cart_id' => 'nullable|exists:carts,id',
30+
'success_url' => 'nullable|url',
31+
'cancel_url' => 'nullable|url',
32+
]);
33+
34+
// Get or create cart for current user/session
35+
$cart = $this->getCartForCheckout($request, $validated['cart_id'] ?? null);
36+
37+
if (!$cart || $cart->items->isEmpty()) {
38+
return response()->json([
39+
'success' => false,
40+
'message' => 'Cart is empty or not found'
41+
], 404);
42+
}
43+
44+
// Validate cart items are still available
45+
foreach ($cart->items as $item) {
46+
if (!$item->isValid()) {
47+
return response()->json([
48+
'success' => false,
49+
'message' => 'Some items in cart are no longer available',
50+
'invalid_items' => [$item->id]
51+
], 422);
52+
}
53+
54+
if (!$item->productVariant->hasStock($item->quantity)) {
55+
return response()->json([
56+
'success' => false,
57+
'message' => 'Insufficient stock for item: ' . $item->productVariant->product->name,
58+
'insufficient_stock_items' => [
59+
'id' => $item->id,
60+
'product' => $item->productVariant->product->name,
61+
'requested' => $item->quantity,
62+
'available' => $item->productVariant->stock_quantity
63+
]
64+
], 422);
65+
}
66+
}
67+
68+
// Create payment intent
69+
$metadata = [
70+
'cart_id' => $cart->id,
71+
'user_id' => $cart->user_id,
72+
'item_count' => $cart->item_count,
73+
];
74+
75+
if (isset($validated['success_url'])) {
76+
$metadata['success_url'] = $validated['success_url'];
77+
}
78+
79+
if (isset($validated['cancel_url'])) {
80+
$metadata['cancel_url'] = $validated['cancel_url'];
81+
}
82+
83+
$paymentIntent = $this->stripeService->createPaymentIntent($cart, $metadata);
84+
85+
// Update cart with payment intent ID
86+
$cart->update(['payment_intent_id' => $paymentIntent->id]);
87+
88+
return response()->json([
89+
'success' => true,
90+
'data' => [
91+
'payment_intent_id' => $paymentIntent->id,
92+
'client_secret' => $paymentIntent->client_secret,
93+
'amount' => $paymentIntent->amount,
94+
'currency' => $paymentIntent->currency,
95+
'cart_id' => $cart->id,
96+
'item_count' => $cart->item_count,
97+
'subtotal' => $cart->subtotal,
98+
],
99+
'message' => 'Checkout initiated successfully'
100+
]);
101+
102+
} catch (\Illuminate\Validation\ValidationException $e) {
103+
return response()->json([
104+
'success' => false,
105+
'message' => 'Validation failed',
106+
'errors' => $e->errors()
107+
], 422);
108+
109+
} catch (\Stripe\Exception\ApiErrorException $e) {
110+
Log::error('Stripe checkout initiation failed', [
111+
'error' => $e->getMessage(),
112+
'cart_id' => $validated['cart_id'] ?? null
113+
]);
114+
115+
return response()->json([
116+
'success' => false,
117+
'message' => 'Payment service unavailable',
118+
'error' => 'Payment processing failed'
119+
], 503);
120+
121+
} catch (\Exception $e) {
122+
Log::error('Checkout initiation failed', [
123+
'error' => $e->getMessage(),
124+
'cart_id' => $validated['cart_id'] ?? null
125+
]);
126+
127+
return response()->json([
128+
'success' => false,
129+
'message' => 'Failed to initiate checkout',
130+
'error' => $e->getMessage()
131+
], 500);
132+
}
133+
}
134+
135+
/**
136+
* Process payment confirmation
137+
*/
138+
public function process(Request $request): JsonResponse
139+
{
140+
try {
141+
$validated = $request->validate([
142+
'payment_intent_id' => 'required|string',
143+
'cart_id' => 'nullable|exists:carts,id',
144+
]);
145+
146+
$paymentIntentId = $validated['payment_intent_id'];
147+
$cartId = $validated['cart_id'];
148+
149+
// Get cart if provided
150+
$cart = null;
151+
if ($cartId) {
152+
$cart = Cart::find($cartId);
153+
}
154+
155+
// Confirm payment intent
156+
$paymentIntent = $this->stripeService->confirmPaymentIntent($paymentIntentId);
157+
158+
if ($paymentIntent->status !== 'succeeded') {
159+
return response()->json([
160+
'success' => false,
161+
'message' => 'Payment not completed',
162+
'payment_status' => $paymentIntent->status
163+
], 402);
164+
}
165+
166+
// Process successful payment
167+
$order = null;
168+
if ($cart) {
169+
$order = $this->processSuccessfulPayment($cart, $paymentIntent);
170+
}
171+
172+
return response()->json([
173+
'success' => true,
174+
'data' => [
175+
'payment_intent_id' => $paymentIntent->id,
176+
'payment_status' => $paymentIntent->status,
177+
'order_id' => $order?->id,
178+
'order_number' => $order?->order_number,
179+
],
180+
'message' => 'Payment processed successfully'
181+
]);
182+
183+
} catch (\Illuminate\Validation\ValidationException $e) {
184+
return response()->json([
185+
'success' => false,
186+
'message' => 'Validation failed',
187+
'errors' => $e->errors()
188+
], 422);
189+
190+
} catch (\Stripe\Exception\CardException $e) {
191+
return response()->json([
192+
'success' => false,
193+
'message' => 'Payment declined',
194+
'error' => $e->getError()->message
195+
], 402);
196+
197+
} catch (\Stripe\Exception\ApiErrorException $e) {
198+
Log::error('Stripe payment processing failed', [
199+
'payment_intent_id' => $validated['payment_intent_id'] ?? null,
200+
'error' => $e->getMessage()
201+
]);
202+
203+
return response()->json([
204+
'success' => false,
205+
'message' => 'Payment processing failed',
206+
'error' => 'Payment service error'
207+
], 503);
208+
209+
} catch (\Exception $e) {
210+
Log::error('Payment processing failed', [
211+
'error' => $e->getMessage(),
212+
'payment_intent_id' => $validated['payment_intent_id'] ?? null
213+
]);
214+
215+
return response()->json([
216+
'success' => false,
217+
'message' => 'Failed to process payment',
218+
'error' => $e->getMessage()
219+
], 500);
220+
}
221+
}
222+
223+
/**
224+
* Handle Stripe webhooks
225+
*/
226+
public function webhook(Request $request): JsonResponse
227+
{
228+
try {
229+
$payload = $request->getContent();
230+
$signature = $request->header('Stripe-Signature');
231+
$webhookSecret = config('services.stripe.webhook_secret');
232+
233+
if (!$signature) {
234+
return response()->json([
235+
'success' => false,
236+
'message' => 'Missing Stripe signature'
237+
], 400);
238+
}
239+
240+
$result = $this->stripeService->handleWebhook($payload, $signature, $webhookSecret);
241+
242+
switch ($result['event']->type) {
243+
case 'payment_intent.succeeded':
244+
$this->stripeService->processPaymentSuccess($result['event']->data);
245+
break;
246+
247+
case 'payment_intent.payment_failed':
248+
$this->stripeService->processPaymentFailure($result['event']->data);
249+
break;
250+
251+
default:
252+
Log::info('Unhandled webhook event', [
253+
'event_type' => $result['event']->type
254+
]);
255+
}
256+
257+
return response()->json([
258+
'success' => true,
259+
'message' => 'Webhook processed successfully'
260+
]);
261+
262+
} catch (\UnexpectedValueException $e) {
263+
return response()->json([
264+
'success' => false,
265+
'message' => 'Invalid webhook payload'
266+
], 400);
267+
268+
} catch (\Stripe\Exception\SignatureVerificationException $e) {
269+
return response()->json([
270+
'success' => false,
271+
'message' => 'Invalid webhook signature'
272+
], 400);
273+
274+
} catch (\Exception $e) {
275+
Log::error('Webhook processing failed', [
276+
'error' => $e->getMessage()
277+
]);
278+
279+
return response()->json([
280+
'success' => false,
281+
'message' => 'Webhook processing failed'
282+
], 500);
283+
}
284+
}
285+
286+
/**
287+
* Get payment intent status
288+
*/
289+
public function paymentStatus(Request $request, string $paymentIntentId): JsonResponse
290+
{
291+
try {
292+
$paymentIntent = $this->stripeService->getPaymentIntent($paymentIntentId);
293+
294+
return response()->json([
295+
'success' => true,
296+
'data' => [
297+
'payment_intent_id' => $paymentIntent->id,
298+
'status' => $paymentIntent->status,
299+
'amount' => $paymentIntent->amount,
300+
'currency' => $paymentIntent->currency,
301+
'last_payment_error' => $paymentIntent->last_payment_error,
302+
],
303+
'message' => 'Payment status retrieved successfully'
304+
]);
305+
306+
} catch (\Stripe\Exception\ApiErrorException $e) {
307+
return response()->json([
308+
'success' => false,
309+
'message' => 'Failed to retrieve payment status',
310+
'error' => 'Payment not found'
311+
], 404);
312+
313+
} catch (\Exception $e) {
314+
return response()->json([
315+
'success' => false,
316+
'message' => 'Failed to retrieve payment status',
317+
'error' => $e->getMessage()
318+
], 500);
319+
}
320+
}
321+
322+
/**
323+
* Get or create cart for checkout
324+
*/
325+
private function getCartForCheckout(Request $request, ?int $cartId = null): ?Cart
326+
{
327+
if ($cartId) {
328+
$cart = Cart::find($cartId);
329+
if ($cart && $cart->is_active) {
330+
return $cart;
331+
}
332+
}
333+
334+
// Get cart for current user/session
335+
$user = auth()->user();
336+
if ($user) {
337+
return Cart::findOrCreateForUser($user);
338+
}
339+
340+
$sessionId = $request->session()->getId();
341+
return Cart::findOrCreateForUser(null, $sessionId);
342+
}
343+
344+
/**
345+
* Process successful payment and create order
346+
*/
347+
private function processSuccessfulPayment(Cart $cart, $paymentIntent): Order
348+
{
349+
return DB::transaction(function () use ($cart, $paymentIntent) {
350+
// Create order
351+
$order = Order::create([
352+
'user_id' => $cart->user_id,
353+
'order_number' => Order::generateOrderNumber(),
354+
'status' => 'paid',
355+
'subtotal' => $cart->subtotal,
356+
'tax_amount' => 0, // Placeholder
357+
'shipping_amount' => 0, // Placeholder
358+
'total_amount' => $cart->subtotal,
359+
'currency' => 'USD',
360+
'payment_status' => 'paid',
361+
'shipping_status' => 'pending',
362+
]);
363+
364+
// Create order items from cart items
365+
foreach ($cart->items as $cartItem) {
366+
$order->items()->create([
367+
'product_variant_id' => $cartItem->product_variant_id,
368+
'quantity' => $cartItem->quantity,
369+
'price_at_time' => $cartItem->price_at_time,
370+
'line_total' => $cartItem->quantity * $cartItem->price_at_time,
371+
]);
372+
373+
// Decrement stock
374+
$cartItem->productVariant->decrement('stock_quantity', $cartItem->quantity);
375+
}
376+
377+
// Create payment record
378+
$order->payments()->create([
379+
'payment_intent_id' => $paymentIntent->id,
380+
'amount' => $paymentIntent->amount / 100, // Convert from cents
381+
'currency' => $paymentIntent->currency,
382+
'status' => 'succeeded',
383+
'payment_method' => $paymentIntent->payment_method_types[0] ?? null,
384+
'metadata' => $paymentIntent->metadata ?? [],
385+
]);
386+
387+
// Clear the cart
388+
$cart->items()->delete();
389+
390+
return $order;
391+
});
392+
}
393+
}

0 commit comments

Comments
 (0)