diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 58eae94..c570a93 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -54,5 +54,14 @@ jobs: DB_PASSWORD_PROD: ${{ secrets.PROD_DB_PASSWORD }} REDIS_PASSWORD_PROD: ${{ secrets.PROD_REDIS_PASSWORD }} + JWT_SECRET_PROD: ${{ secrets.PROD_JWT_SECRET }} + ORDER_SERVICE_KEY_PROD: ${{ secrets.PROD_ORDER_SERVICE_KEY }} + STRIPE_SECRET_KEY_PROD: ${{ secrets.PROD_STRIPE_SECRET_KEY }} + STRIPE_WEBHOOK_SECRET_PROD: ${{ secrets.PROD_STRIPE_WEBHOOK_SECRET }} + DB_PASSWORD_DEV: ${{ secrets.DEV_DB_PASSWORD }} - REDIS_PASSWORD_DEV: ${{ secrets.DEV_REDIS_PASSWORD }} \ No newline at end of file + REDIS_PASSWORD_DEV: ${{ secrets.DEV_REDIS_PASSWORD }} + JWT_SECRET_DEV: ${{ secrets.DEV_JWT_SECRET }} + ORDER_SERVICE_KEY_DEV: ${{ secrets.DEV_ORDER_SERVICE_KEY }} + STRIPE_SECRET_KEY_DEV: ${{ secrets.DEV_STRIPE_SECRET_KEY }} + STRIPE_WEBHOOK_SECRET_DEV: ${{ secrets.DEV_STRIPE_WEBHOOK_SECRET }} \ No newline at end of file diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php new file mode 100644 index 0000000..77c380c --- /dev/null +++ b/app/Http/Controllers/CheckoutController.php @@ -0,0 +1,82 @@ +attributes->get('user_id'); + } + + public function create(Request $request) + { + $data = $request->validate([ + 'order_id' => ['required', 'integer', 'min:1'], + ]); + + $orderId = (int) $data['order_id']; + + // 1) Fetch order from order-service as the user (so ownership is enforced) + $ordersBase = rtrim((string) config('services.orders.base_url'), '/'); // http://web/api/orders + $orderUrl = $ordersBase . '/items/' . $orderId; + + $authHeader = (string) $request->header('Authorization'); // reuse same user token + $orderResp = Http::acceptJson() + ->withHeaders(['Authorization' => $authHeader]) + ->get($orderUrl); + + if (!$orderResp->successful()) { + return response()->json(['message' => 'Order not found'], 404); + } + + $order = $orderResp->json('data'); + if (!$order || !isset($order['id'], $order['total_price'], $order['status'])) { + return response()->json(['message' => 'Invalid order response'], 500); + } + + if ((string) $order['status'] === 'paid') { + return response()->json(['message' => 'Order already paid'], 422); + } + + $amount = (int) $order['total_price']; // cents + if ($amount <= 0) { + return response()->json(['message' => 'Invalid order amount'], 422); + } + + // 2) Create Stripe Checkout Session + $stripe = new StripeClient((string) config('services.stripe.secret')); + + $frontend = rtrim((string) config('services.frontend.base_url'), '/'); // http://app.localhost + $currency = (string) config('services.stripe.currency'); + + $session = $stripe->checkout->sessions->create([ + 'mode' => 'payment', + 'success_url' => $frontend . '/checkout/success?order_id=' . $orderId . '&session_id={CHECKOUT_SESSION_ID}', + 'cancel_url' => $frontend . '/checkout/cancel?order_id=' . $orderId, + 'line_items' => [[ + 'quantity' => 1, + 'price_data' => [ + 'currency' => $currency, + 'unit_amount' => $amount, + 'product_data' => [ + 'name' => 'Order #' . $orderId, + ], + ], + ]], + 'metadata' => [ + 'order_id' => (string) $orderId, + 'user_id' => (string) $this->userId($request), + ], + ]); + + return response()->json([ + 'url' => $session->url, + 'session_id' => $session->id, + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/StripeWebhookController.php b/app/Http/Controllers/StripeWebhookController.php new file mode 100644 index 0000000..c78b649 --- /dev/null +++ b/app/Http/Controllers/StripeWebhookController.php @@ -0,0 +1,42 @@ +getContent(); + $sigHeader = (string) $request->header('Stripe-Signature', ''); + $secret = (string) config('services.stripe.webhook_secret'); + + try { + $event = Webhook::constructEvent($payload, $sigHeader, $secret); + } catch (\Throwable $e) { + return response()->json(['message' => 'Invalid signature'], 400); + } + + if ($event->type === 'checkout.session.completed') { + $session = $event->data->object; + + $orderId = $session->metadata->order_id ?? null; + + if ($orderId) { + $ordersBase = rtrim((string) config('services.orders.base_url'), '/'); // http://web/api/orders + $serviceKey = (string) config('services.orders.service_key'); + + $markPaidUrl = $ordersBase . '/internal/items/' . $orderId . '/mark-paid'; + + Http::acceptJson() + ->withHeaders(['X-Service-Key' => $serviceKey]) + ->post($markPaidUrl); + } + } + + return response()->json(['received' => true]); + } +} \ No newline at end of file diff --git a/app/Http/Middleware/JwtAuth.php b/app/Http/Middleware/JwtAuth.php new file mode 100644 index 0000000..19117cd --- /dev/null +++ b/app/Http/Middleware/JwtAuth.php @@ -0,0 +1,36 @@ +header('Authorization', ''); + + if (!str_starts_with($authHeader, 'Bearer ')) { + return response()->json(['message' => 'Missing Bearer token'], 401); + } + + $token = trim(substr($authHeader, 7)); + + try { + $claims = app(JwtService::class)->decode($token); + $userId = $claims['sub'] ?? null; + + if (!$userId) { + return response()->json(['message' => 'Invalid token'], 401); + } + + $request->attributes->set('user_id', (int) $userId); + + return $next($request); + } catch (\Throwable) { + return response()->json(['message' => 'Invalid or expired token'], 401); + } + } +} \ No newline at end of file diff --git a/app/Services/JwtService.php b/app/Services/JwtService.php new file mode 100644 index 0000000..0d767e9 --- /dev/null +++ b/app/Services/JwtService.php @@ -0,0 +1,20 @@ +withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'jwt' => \App\Http\Middleware\JwtAuth::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index 52a3a8a..79a5907 100644 --- a/composer.json +++ b/composer.json @@ -7,8 +7,10 @@ "license": "MIT", "require": { "php": "^8.2", + "firebase/php-jwt": "^7.0", "laravel/framework": "^12.0", - "laravel/tinker": "^2.10.1" + "laravel/tinker": "^2.10.1", + "stripe/stripe-php": "^19.2" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 6d1ffb6..75facea 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c514d8f7b9fc5970bdd94287905ef584", + "content-hash": "a8adab13f8ae33d4499d86fd3642bb42", "packages": [ { "name": "brick/math", @@ -508,6 +508,69 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.0.2", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v7.0.2" + }, + "time": "2025-12-16T22:17:28+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -3290,6 +3353,65 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "stripe/stripe-php", + "version": "v19.2.0", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "e444fbc524cd3d6583dde2c5b375d26174c54d96" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/e444fbc524cd3d6583dde2c5b375d26174c54d96", + "reference": "e444fbc524cd3d6583dde2c5b375d26174c54d96", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=5.6.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.72.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^5.7 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v19.2.0" + }, + "time": "2026-01-16T21:28:14+00:00" + }, { "name": "symfony/clock", "version": "v8.0.0", diff --git a/config/jwt.php b/config/jwt.php new file mode 100644 index 0000000..8dde708 --- /dev/null +++ b/config/jwt.php @@ -0,0 +1,4 @@ + env('JWT_SECRET'), +]; \ No newline at end of file diff --git a/config/services.php b/config/services.php index 6a90eb8..529a80e 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,20 @@ ], ], + + 'orders' => [ + 'base_url' => env('ORDER_SERVICE_BASE_URL'), + 'service_key' => env('ORDER_SERVICE_KEY'), + ], + + 'stripe' => [ + 'secret' => env('STRIPE_SECRET_KEY'), + 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), + 'currency' => env('STRIPE_CURRENCY', 'eur'), + ], + + 'frontend' => [ + 'base_url' => env('FRONTEND_BASE_URL'), + ], + ]; diff --git a/routes/api.php b/routes/api.php index 71a496c..3411ada 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,7 @@ json([ - [ - 'id' => 1, - 'name' => 'Payment 1', - 'total_value' => 49.90, - 'currency' => 'EUR', - ], - [ - 'id' => 2, - 'name' => 'Payment 2', - 'total_cost' => 19.90, - 'currency' => 'EUR', - ], - [ - 'id' => 3, - 'name' => 'Payment 3', - 'total_cost' => 12.90, - 'currency' => 'EUR', - ], - ]); -}); - +Route::middleware('jwt')->post('/checkout-session', [CheckoutController::class, 'create']); +Route::post('/webhooks/stripe', [StripeWebhookController::class, 'handle']);