From 248fb21a05dd37b8b564fa7aa7bf7599285042c7 Mon Sep 17 00:00:00 2001 From: Marco Wende Date: Sun, 21 Jun 2026 11:24:02 +0200 Subject: [PATCH] Add PayPal, Stripe, CSV-Export; fix .env.production issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PayPal: Checkout-Integration via srmklive/paypal, Weiterleitung zu PayPal, Bestellung wird nach Capture automatisch auf payment_received gesetzt - Stripe: Payment Elements im Checkout, Intent-API, Zahlung direkt bestätigt - CSV-Export: Admin → Bestellungen → CSV exportieren (UTF-8 BOM für Excel) - PayPal/Stripe nur sichtbar wenn Keys in .env gesetzt - .env.example um PAYPAL_* und STRIPE_* Keys erweitert Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 9 ++ .../Controllers/Admin/OrderController.php | 35 +++++ .../Controllers/Shop/CheckoutController.php | 4 + .../Controllers/Shop/PayPalController.php | 104 ++++++++++++++ .../Controllers/Shop/StripeController.php | 69 ++++++++++ app/Models/Order.php | 4 +- composer.json | 4 +- composer.lock | 128 +++++++++++++++++- config/paypal.php | 31 +++++ config/services.php | 5 + ...01_000022_add_payment_fields_to_orders.php | 25 ++++ resources/views/admin/orders/index.blade.php | 5 +- resources/views/shop/checkout/index.blade.php | 108 ++++++++++++++- routes/web.php | 10 ++ 14 files changed, 529 insertions(+), 12 deletions(-) create mode 100644 app/Http/Controllers/Shop/PayPalController.php create mode 100644 app/Http/Controllers/Shop/StripeController.php create mode 100644 config/paypal.php create mode 100644 database/migrations/2024_01_01_000022_add_payment_fields_to_orders.php diff --git a/.env.example b/.env.example index 172d98c..d5df810 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,12 @@ MAIL_FROM_ADDRESS="shop@example.de" MAIL_FROM_NAME="Mein Online-Shop" VITE_APP_NAME="${APP_NAME}" + +PAYPAL_MODE=sandbox +PAYPAL_SANDBOX_CLIENT_ID= +PAYPAL_SANDBOX_CLIENT_SECRET= +PAYPAL_LIVE_CLIENT_ID= +PAYPAL_LIVE_CLIENT_SECRET= + +STRIPE_KEY= +STRIPE_SECRET= diff --git a/app/Http/Controllers/Admin/OrderController.php b/app/Http/Controllers/Admin/OrderController.php index 444e729..79f6963 100644 --- a/app/Http/Controllers/Admin/OrderController.php +++ b/app/Http/Controllers/Admin/OrderController.php @@ -76,4 +76,39 @@ class OrderController extends Controller { return $this->invoiceService->generate($order)->download($this->invoiceService->filename($order)); } + + public function exportCsv() + { + $orders = Order::with(['items','billingAddress'])->orderByDesc('created_at')->get(); + + $rows = []; + $rows[] = ['Bestellnr.','Datum','Status','Zahlungsart','Kunde','E-Mail','Zwischensumme','Rabatt','Versand','Gesamt','MwSt.']; + + foreach ($orders as $order) { + $addr = $order->billingAddress(); + $rows[] = [ + $order->order_number, + $order->created_at->format('d.m.Y H:i'), + Order::$statusLabels[$order->status] ?? $order->status, + Order::$paymentLabels[$order->payment_method] ?? $order->payment_method, + $addr ? $addr->full_name : ($order->guest_email ?? ''), + $order->guest_email ?? $order->user?->email ?? '', + number_format($order->subtotal, 2, ',', '.'), + number_format($order->discount_amount ?? 0, 2, ',', '.'), + number_format($order->shipping_cost, 2, ',', '.'), + number_format($order->total, 2, ',', '.'), + number_format($order->tax_total, 2, ',', '.'), + ]; + } + + $csv = "\xEF\xBB\xBF"; // UTF-8 BOM für Excel + foreach ($rows as $row) { + $csv .= implode(';', array_map(fn($v) => '"' . str_replace('"', '""', $v) . '"', $row)) . "\n"; + } + + return response($csv, 200, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename="bestellungen-' . date('Y-m-d') . '.csv"', + ]); + } } diff --git a/app/Http/Controllers/Shop/CheckoutController.php b/app/Http/Controllers/Shop/CheckoutController.php index b467407..6e36eab 100644 --- a/app/Http/Controllers/Shop/CheckoutController.php +++ b/app/Http/Controllers/Shop/CheckoutController.php @@ -103,6 +103,10 @@ class CheckoutController extends Controller session(['checkout_data' => $data]); + if ($data['payment_method'] === 'paypal') { + return redirect()->route('paypal.redirect'); + } + return redirect()->route('checkout.confirm'); } diff --git a/app/Http/Controllers/Shop/PayPalController.php b/app/Http/Controllers/Shop/PayPalController.php new file mode 100644 index 0000000..7b3d1cc --- /dev/null +++ b/app/Http/Controllers/Shop/PayPalController.php @@ -0,0 +1,104 @@ +cart->count() === 0) { + return redirect()->route('checkout.index'); + } + + $subtotal = $this->cart->subtotal(); + $country = $checkoutData['shipping']['country'] ?? $checkoutData['billing']['country'] ?? 'DE'; + $shippingRate = ShippingRate::forCart($subtotal, $country); + $shippingCost = $shippingRate ? $shippingRate->getCostForTotal($subtotal) : 0; + $discount = 0; + if ($code = session('coupon_code')) { + $coupon = Coupon::findValid($code); + if ($coupon) $discount = $coupon->calcDiscount($subtotal); + } + $total = max(0, $subtotal - $discount) + $shippingCost; + + $provider = new PayPalClient; + $provider->setApiCredentials(config('paypal')); + $provider->getAccessToken(); + + $order = $provider->createOrder([ + 'intent' => 'CAPTURE', + 'purchase_units' => [[ + 'amount' => [ + 'currency_code' => 'EUR', + 'value' => number_format($total, 2, '.', ''), + ], + 'description' => config('app.name') . ' Bestellung', + ]], + 'application_context' => [ + 'return_url' => route('paypal.success'), + 'cancel_url' => route('checkout.confirm'), + ], + ]); + + if (isset($order['id'])) { + session(['paypal_order_id' => $order['id']]); + foreach ($order['links'] as $link) { + if ($link['rel'] === 'approve') { + return redirect($link['href']); + } + } + } + + return redirect()->route('checkout.confirm')->with('error', 'PayPal-Verbindung fehlgeschlagen. Bitte versuchen Sie es erneut.'); + } + + public function success(Request $request) + { + $checkoutData = session('checkout_data'); + $paypalOrderId = session('paypal_order_id'); + + if (!$checkoutData || !$paypalOrderId) { + return redirect()->route('checkout.index'); + } + + $provider = new PayPalClient; + $provider->setApiCredentials(config('paypal')); + $provider->getAccessToken(); + + $result = $provider->capturePaymentOrder($paypalOrderId); + + if (($result['status'] ?? '') !== 'COMPLETED') { + return redirect()->route('checkout.confirm')->with('error', 'PayPal-Zahlung nicht abgeschlossen.'); + } + + $checkoutData['payment_method'] = 'paypal'; + $order = $this->orderService->createFromSession($checkoutData, 'paypal'); + $order->update([ + 'payment_id' => $paypalOrderId, + 'status' => 'payment_received', + 'paid_at' => now(), + ]); + + session()->forget(['checkout_data', 'checkout_country', 'paypal_order_id']); + + return redirect()->route('checkout.thankyou', $order->order_number); + } + + public function cancel() + { + session()->forget('paypal_order_id'); + return redirect()->route('checkout.confirm')->with('error', 'PayPal-Zahlung abgebrochen.'); + } +} diff --git a/app/Http/Controllers/Shop/StripeController.php b/app/Http/Controllers/Shop/StripeController.php new file mode 100644 index 0000000..db68200 --- /dev/null +++ b/app/Http/Controllers/Shop/StripeController.php @@ -0,0 +1,69 @@ +cart->subtotal(); + $country = session('checkout_country', 'DE'); + $shippingRate = ShippingRate::forCart($subtotal, $country); + $shippingCost = $shippingRate ? $shippingRate->getCostForTotal($subtotal) : 0; + $discount = 0; + if ($code = session('coupon_code')) { + $coupon = Coupon::findValid($code); + if ($coupon) $discount = $coupon->calcDiscount($subtotal); + } + $total = max(0, $subtotal - $discount) + $shippingCost; + + $stripe = new StripeClient(config('services.stripe.secret')); + $intent = $stripe->paymentIntents->create([ + 'amount' => (int) round($total * 100), + 'currency' => 'eur', + 'automatic_payment_methods' => ['enabled' => true], + ]); + + return response()->json(['client_secret' => $intent->client_secret]); + } + + public function success(Request $request) + { + $checkoutData = session('checkout_data'); + $paymentIntent = $request->input('payment_intent'); + + if (!$checkoutData || !$paymentIntent) { + return redirect()->route('checkout.index'); + } + + $stripe = new StripeClient(config('services.stripe.secret')); + $intent = $stripe->paymentIntents->retrieve($paymentIntent); + + if ($intent->status !== 'succeeded') { + return redirect()->route('checkout.confirm')->with('error', 'Zahlung nicht abgeschlossen.'); + } + + $checkoutData['payment_method'] = 'stripe'; + $order = $this->orderService->createFromSession($checkoutData, 'stripe'); + $order->update([ + 'payment_id' => $paymentIntent, + 'status' => 'payment_received', + 'paid_at' => now(), + ]); + + session()->forget(['checkout_data', 'checkout_country']); + + return redirect()->route('checkout.thankyou', $order->order_number); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php index 1e3a5d5..765d074 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class Order extends Model { protected $fillable = [ - 'order_number','user_id','guest_email','status','payment_method', + 'order_number','user_id','guest_email','status','payment_method','payment_id', 'coupon_code','discount_amount', 'subtotal','tax_total','shipping_cost','total','tracking_code','notes','paid_at', ]; @@ -35,6 +35,8 @@ class Order extends Model public static array $paymentLabels = [ 'prepayment' => 'Vorkasse', 'invoice' => 'Rechnung', + 'paypal' => 'PayPal', + 'stripe' => 'Kreditkarte (Stripe)', ]; public function user(): BelongsTo diff --git a/composer.json b/composer.json index b59099e..f57d001 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,9 @@ "barryvdh/laravel-dompdf": "^3.1", "laravel/breeze": "^2.4", "laravel/framework": "^13.8", - "laravel/tinker": "^3.0" + "laravel/tinker": "^3.0", + "srmklive/paypal": "^3.1", + "stripe/stripe-php": "^20.2" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 6e0fdbf..f3916f1 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": "7c85fc2534f4d32bbb1b8b81e2f24574", + "content-hash": "21ae14678f4d08df3df9ad60a51f440e", "packages": [ { "name": "barryvdh/laravel-dompdf", @@ -3744,6 +3744,132 @@ }, "time": "2026-06-18T15:10:53+00:00" }, + { + "name": "srmklive/paypal", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/blendbyte/laravel-paypal.git", + "reference": "522834a22fbd10e8fa1be21cb7c84d3d45c3a763" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/blendbyte/laravel-paypal/zipball/522834a22fbd10e8fa1be21cb7c84d3d45c3a763", + "reference": "522834a22fbd10e8fa1be21cb7c84d3d45c3a763", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9", + "guzzlehttp/psr7": "^2.0", + "illuminate/support": "^12.0|^13.0", + "nesbot/carbon": "^3.0", + "php": "^8.2", + "psr/http-client": "^1.0" + }, + "require-dev": { + "larastan/larastan": "^3.0", + "laravel/framework": "^12.0|^13.0", + "laravel/pint": "^1.29", + "orchestra/testbench": "^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "phpunit/phpunit": "^11.0|^12.0", + "symfony/var-dumper": "^7.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PayPal": "Srmklive\\PayPal\\Facades\\PayPal" + }, + "providers": [ + "Srmklive\\PayPal\\Providers\\PayPalServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Srmklive\\PayPal\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PayPal REST API client for Laravel and standalone PHP.", + "keywords": [ + "REST API", + "laravel", + "payments", + "paypal" + ], + "support": { + "issues": "https://github.com/blendbyte/laravel-paypal/issues", + "source": "https://github.com/blendbyte/laravel-paypal/tree/3.1.1" + }, + "time": "2026-04-18T05:16:56+00:00" + }, + { + "name": "stripe/stripe-php", + "version": "v20.2.1", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "c628cfa0b3de4ef5110b2c2bfbf881a33a52fdd5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/c628cfa0b3de4ef5110b2c2bfbf881a33a52fdd5", + "reference": "c628cfa0b3de4ef5110b2c2bfbf881a33a52fdd5", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=7.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.94.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "files": [ + "lib/version_check.php" + ], + "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/v20.2.1" + }, + "time": "2026-06-12T22:41:56+00:00" + }, { "name": "symfony/clock", "version": "v8.1.0", diff --git a/config/paypal.php b/config/paypal.php new file mode 100644 index 0000000..0676ad2 --- /dev/null +++ b/config/paypal.php @@ -0,0 +1,31 @@ +. + */ + +return [ + 'mode' => env('PAYPAL_MODE', 'sandbox'), // Can only be 'sandbox' Or 'live'. If empty or invalid, 'live' will be used. + 'sandbox' => [ + 'client_id' => env('PAYPAL_SANDBOX_CLIENT_ID', ''), + 'client_secret' => env('PAYPAL_SANDBOX_CLIENT_SECRET', ''), + 'app_id' => 'APP-80W284485P519543T', // Sandbox app_id is always this fixed value. + ], + 'live' => [ + 'client_id' => env('PAYPAL_LIVE_CLIENT_ID', ''), + 'client_secret' => env('PAYPAL_LIVE_CLIENT_SECRET', ''), + // Live app_id: log in to developer.paypal.com → My Apps & Credentials → + // select your app → the App ID shown at the top (starts with "APP-"). + 'app_id' => env('PAYPAL_LIVE_APP_ID', ''), + ], + + 'payment_action' => env('PAYPAL_PAYMENT_ACTION', 'Sale'), // Can only be 'Sale', 'Authorization' or 'Order' + 'currency' => env('PAYPAL_CURRENCY', 'USD'), + 'notify_url' => env('PAYPAL_NOTIFY_URL', ''), // Change this accordingly for your application. + 'locale' => env('PAYPAL_LOCALE', 'en_US'), // force gateway language i.e. it_IT, es_ES, en_US ... (for express checkout only) + 'validate_ssl' => env('PAYPAL_VALIDATE_SSL', true), // Validate SSL when creating api client. + 'timeout' => env('PAYPAL_TIMEOUT', 30), // Total request timeout in seconds. + 'connect_timeout' => env('PAYPAL_CONNECT_TIMEOUT', 10), // Connection timeout in seconds. + 'max_retries' => env('PAYPAL_MAX_RETRIES', 2), // Retries on 5xx / connection errors (0 to disable). Uses exponential backoff. +]; diff --git a/config/services.php b/config/services.php index 6a90eb8..dc29883 100644 --- a/config/services.php +++ b/config/services.php @@ -28,6 +28,11 @@ return [ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], + 'stripe' => [ + 'key' => env('STRIPE_KEY'), + 'secret' => env('STRIPE_SECRET'), + ], + 'slack' => [ 'notifications' => [ 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), diff --git a/database/migrations/2024_01_01_000022_add_payment_fields_to_orders.php b/database/migrations/2024_01_01_000022_add_payment_fields_to_orders.php new file mode 100644 index 0000000..27116ed --- /dev/null +++ b/database/migrations/2024_01_01_000022_add_payment_fields_to_orders.php @@ -0,0 +1,25 @@ +string('payment_id')->nullable()->after('payment_method'); + }); + + // payment_method enum um paypal und stripe erweitern + DB::statement("ALTER TABLE orders MODIFY COLUMN payment_method ENUM('prepayment','invoice','paypal','stripe') NOT NULL DEFAULT 'prepayment'"); + } + + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + $table->dropColumn('payment_id'); + }); + DB::statement("ALTER TABLE orders MODIFY COLUMN payment_method ENUM('prepayment','invoice') NOT NULL DEFAULT 'prepayment'"); + } +}; diff --git a/resources/views/admin/orders/index.blade.php b/resources/views/admin/orders/index.blade.php index a47e68e..8c5fca3 100644 --- a/resources/views/admin/orders/index.blade.php +++ b/resources/views/admin/orders/index.blade.php @@ -1,9 +1,10 @@ @extends('layouts.admin') @section('title', 'Bestellungen') @section('content') -
+

Bestellungen

-
+ ↓ CSV exportieren + + -
@@ -174,9 +257,20 @@ @endif - + {{-- PayPal: Adresse speichern, dann zu PayPal weiterleiten --}} +
+ + +
diff --git a/routes/web.php b/routes/web.php index a34f8cf..ffa5dc0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -25,6 +25,15 @@ Route::post('/kasse/adresse', [Shop\CheckoutController::class, 'storeAddress'])- Route::get('/kasse/zahlung', [Shop\CheckoutController::class, 'payment'])->name('checkout.payment'); Route::match(['GET','POST'], '/kasse/bestaetigen', [Shop\CheckoutController::class, 'confirm'])->name('checkout.confirm'); Route::get('/kasse/danke/{order}', [Shop\CheckoutController::class, 'thankyou'])->name('checkout.thankyou'); +// PayPal +Route::get('/kasse/paypal/weiterleitung', [Shop\PayPalController::class, 'redirect'])->name('paypal.redirect'); +Route::get('/kasse/paypal/erfolg', [Shop\PayPalController::class, 'success'])->name('paypal.success'); +Route::get('/kasse/paypal/abbruch', [Shop\PayPalController::class, 'cancel'])->name('paypal.cancel'); + +// Stripe +Route::post('/kasse/stripe/intent', [Shop\StripeController::class, 'createIntent'])->name('stripe.intent'); +Route::get('/kasse/stripe/erfolg', [Shop\StripeController::class, 'success'])->name('stripe.success'); + Route::post('/kasse/gutschein', [Shop\CheckoutController::class, 'applyCoupon'])->name('checkout.coupon.apply'); Route::post('/kasse/gutschein/entfernen', [Shop\CheckoutController::class, 'removeCoupon'])->name('checkout.coupon.remove'); Route::get('/kasse/versandkosten', [Shop\CheckoutController::class, 'shippingCost'])->name('checkout.shipping.cost'); @@ -86,6 +95,7 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun // Bestellungen Route::get('orders', [Admin\OrderController::class, 'index'])->name('orders.index'); + Route::get('orders/export/csv', [Admin\OrderController::class, 'exportCsv'])->name('orders.export'); Route::get('orders/{order}', [Admin\OrderController::class, 'show'])->name('orders.show'); Route::put('orders/{order}/status', [Admin\OrderController::class, 'updateStatus'])->name('orders.status'); Route::post('orders/{order}/invoice', [Admin\OrderController::class, 'sendInvoice'])->name('orders.invoice');