switched to paddle inline checkout, removed paypal and most of stripe. added product sync between app and paddle.
This commit is contained in:
@@ -5,21 +5,17 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use PayPal\Checkout\Orders\OrdersCaptureRequest;
|
||||
use PayPal\Checkout\Orders\OrdersCreateRequest;
|
||||
use PayPal\Environment\LiveEnvironment;
|
||||
use PayPal\Environment\SandboxEnvironment;
|
||||
use PayPal\PayPalClient;
|
||||
|
||||
class PackageController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$type = $request->query('type', 'endcustomer');
|
||||
@@ -51,7 +47,7 @@ class PackageController extends Controller
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'type' => 'required|in:endcustomer,reseller',
|
||||
'payment_method' => 'required|in:stripe,paypal',
|
||||
'payment_method' => 'required|in:stripe,paddle',
|
||||
'event_id' => 'nullable|exists:events,id', // For endcustomer
|
||||
]);
|
||||
|
||||
@@ -105,8 +101,8 @@ class PackageController extends Controller
|
||||
{
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'payment_method_id' => 'required_without:paypal_order_id|string',
|
||||
'paypal_order_id' => 'required_without:payment_method_id|string',
|
||||
'payment_method_id' => 'required_without:paddle_transaction_id|string',
|
||||
'paddle_transaction_id' => 'required_without:payment_method_id|string',
|
||||
]);
|
||||
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
@@ -116,13 +112,14 @@ class PackageController extends Controller
|
||||
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
|
||||
}
|
||||
|
||||
$provider = $request->has('paypal_order_id') ? 'paypal' : 'stripe';
|
||||
$provider = $request->has('paddle_transaction_id') ? 'paddle' : 'stripe';
|
||||
|
||||
DB::transaction(function () use ($request, $package, $tenant, $provider) {
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'provider_id' => $request->input($provider === 'paypal' ? 'paypal_order_id' : 'payment_method_id'),
|
||||
'provider' => $provider,
|
||||
'provider_id' => $request->input($provider === 'paddle' ? 'paddle_transaction_id' : 'payment_method_id'),
|
||||
'price' => $package->price,
|
||||
'type' => 'endcustomer_event',
|
||||
'purchased_at' => now(),
|
||||
@@ -165,6 +162,7 @@ class PackageController extends Controller
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'provider' => 'free',
|
||||
'provider_id' => 'free_wizard',
|
||||
'price' => $package->price,
|
||||
'type' => 'endcustomer_event',
|
||||
@@ -186,156 +184,33 @@ class PackageController extends Controller
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function createPayPalOrder(Request $request): JsonResponse
|
||||
public function createPaddleCheckout(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'success_url' => 'nullable|url',
|
||||
'return_url' => 'nullable|url',
|
||||
]);
|
||||
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
$package = Package::findOrFail($request->integer('package_id'));
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
if (! $tenant) {
|
||||
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
|
||||
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
|
||||
}
|
||||
|
||||
$environment = config('services.paypal.sandbox', true) ? new SandboxEnvironment(
|
||||
config('services.paypal.client_id'),
|
||||
config('services.paypal.secret')
|
||||
) : new LiveEnvironment(
|
||||
config('services.paypal.client_id'),
|
||||
config('services.paypal.secret')
|
||||
);
|
||||
if (! $package->paddle_price_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||
}
|
||||
|
||||
$client = PayPalClient::client($environment);
|
||||
|
||||
$request = new OrdersCreateRequest;
|
||||
$request->prefer('return=representation');
|
||||
$request->body = [
|
||||
'intent' => 'CAPTURE',
|
||||
'purchase_units' => [[
|
||||
'amount' => [
|
||||
'currency_code' => 'EUR',
|
||||
'value' => number_format($package->price, 2, '.', ''),
|
||||
],
|
||||
'description' => 'Fotospiel Package: '.$package->name,
|
||||
'custom_id' => json_encode([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'user_id' => $tenant->user_id ?? null,
|
||||
]),
|
||||
]],
|
||||
'application_context' => [
|
||||
'shipping_preference' => 'NO_SHIPPING',
|
||||
'user_action' => 'PAY_NOW',
|
||||
],
|
||||
$payload = [
|
||||
'success_url' => $request->input('success_url'),
|
||||
'return_url' => $request->input('return_url'),
|
||||
];
|
||||
|
||||
try {
|
||||
$response = $client->execute($request);
|
||||
$order = $response->result;
|
||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
|
||||
|
||||
return response()->json([
|
||||
'orderID' => $order->id,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('PayPal order creation error: '.$e->getMessage());
|
||||
throw ValidationException::withMessages(['payment' => 'PayPal-Bestellung fehlgeschlagen.']);
|
||||
}
|
||||
}
|
||||
|
||||
public function capturePayPalOrder(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'order_id' => 'required|string',
|
||||
]);
|
||||
|
||||
$orderId = $request->order_id;
|
||||
|
||||
$environment = config('services.paypal.sandbox', true) ? new SandboxEnvironment(
|
||||
config('services.paypal.client_id'),
|
||||
config('services.paypal.secret')
|
||||
) : new LiveEnvironment(
|
||||
config('services.paypal.client_id'),
|
||||
config('services.paypal.secret')
|
||||
);
|
||||
|
||||
$client = PayPalClient::client($environment);
|
||||
|
||||
$request = new OrdersCaptureRequest($orderId);
|
||||
$request->prefer('return=representation');
|
||||
|
||||
try {
|
||||
$response = $client->execute($request);
|
||||
$capture = $response->result;
|
||||
|
||||
if ($capture->status !== 'COMPLETED') {
|
||||
throw new \Exception('PayPal capture not completed: '.$capture->status);
|
||||
}
|
||||
|
||||
$customId = $capture->purchaseUnits[0]->customId ?? null;
|
||||
if (! $customId) {
|
||||
throw new \Exception('No metadata in PayPal order');
|
||||
}
|
||||
|
||||
$metadata = json_decode($customId, true);
|
||||
$tenant = Tenant::find($metadata['tenant_id']);
|
||||
$package = Package::find($metadata['package_id']);
|
||||
|
||||
if (! $tenant || ! $package) {
|
||||
throw new \Exception('Tenant or package not found');
|
||||
}
|
||||
|
||||
// Idempotent check
|
||||
$existing = PackagePurchase::where('provider_id', $orderId)->first();
|
||||
if ($existing) {
|
||||
return response()->json(['success' => true, 'message' => 'Already processed']);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($tenant, $package, $orderId) {
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'provider_id' => $orderId,
|
||||
'price' => $package->price,
|
||||
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
|
||||
'purchased_at' => now(),
|
||||
'metadata' => json_encode(['paypal_order' => $orderId]),
|
||||
]);
|
||||
|
||||
// Trial logic for first reseller subscription
|
||||
$activePackages = TenantPackage::where('tenant_id', $tenant->id)
|
||||
->where('active', true)
|
||||
->where('package_id', '!=', $package->id) // Exclude current if renewing
|
||||
->count();
|
||||
|
||||
$expiresAt = now()->addYear();
|
||||
if ($activePackages === 0 && $package->type === 'reseller_subscription') {
|
||||
$expiresAt = now()->addDays(14); // 14-day trial
|
||||
Log::info('PayPal trial activated for tenant', ['tenant_id' => $tenant->id]);
|
||||
}
|
||||
|
||||
TenantPackage::updateOrCreate(
|
||||
['tenant_id' => $tenant->id, 'package_id' => $package->id],
|
||||
[
|
||||
'price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'active' => true,
|
||||
'expires_at' => $expiresAt,
|
||||
]
|
||||
);
|
||||
|
||||
$tenant->update(['subscription_status' => 'active']);
|
||||
});
|
||||
|
||||
Log::info('PayPal order captured successfully', ['order_id' => $orderId, 'tenant_id' => $tenant->id]);
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Payment successful']);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('PayPal capture error: '.$e->getMessage(), ['order_id' => $orderId]);
|
||||
|
||||
return response()->json(['success' => false, 'message' => 'Capture failed: '.$e->getMessage()], 422);
|
||||
}
|
||||
return response()->json($checkout);
|
||||
}
|
||||
|
||||
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||
@@ -345,6 +220,7 @@ class PackageController extends Controller
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $request->event_id,
|
||||
'package_id' => $package->id,
|
||||
'provider' => 'free',
|
||||
'provider_id' => 'free',
|
||||
'price' => $package->price,
|
||||
'type' => $request->type,
|
||||
@@ -397,7 +273,4 @@ class PackageController extends Controller
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for PayPal client - add this if not exists, or use global
|
||||
// But since SDK has PayPalClient, assume it's used
|
||||
}
|
||||
|
||||
63
app/Http/Controllers/Api/TenantBillingController.php
Normal file
63
app/Http/Controllers/Api/TenantBillingController.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TenantBillingController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PaddleTransactionService $paddleTransactions) {}
|
||||
|
||||
public function transactions(Request $request): JsonResponse
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
if (! $tenant) {
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'message' => 'Tenant not found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
if (! $tenant->paddle_customer_id) {
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'message' => 'Tenant has no Paddle customer identifier.',
|
||||
]);
|
||||
}
|
||||
|
||||
$cursor = $request->query('cursor');
|
||||
$perPage = (int) $request->query('per_page', 25);
|
||||
|
||||
$query = [
|
||||
'per_page' => max(1, min($perPage, 100)),
|
||||
];
|
||||
|
||||
if ($cursor) {
|
||||
$query['after'] = $cursor;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->paddleTransactions->listForCustomer($tenant->paddle_customer_id, $query);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Failed to load Paddle transactions', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'message' => 'Failed to load Paddle transactions.',
|
||||
], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $result['data'],
|
||||
'meta' => $result['meta'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,18 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
@@ -38,7 +37,7 @@ class RegisteredUserController extends Controller
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$fullName = trim($request->first_name . ' ' . $request->last_name);
|
||||
$fullName = trim($request->first_name.' '.$request->last_name);
|
||||
|
||||
$validated = $request->validate([
|
||||
'username' => ['required', 'string', 'max:255', 'unique:'.User::class],
|
||||
@@ -73,7 +72,7 @@ class RegisteredUserController extends Controller
|
||||
$tenant = Tenant::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $fullName,
|
||||
'slug' => Str::slug($fullName . '-' . now()->timestamp),
|
||||
'slug' => Str::slug($fullName.'-'.now()->timestamp),
|
||||
'email' => $request->email,
|
||||
'is_active' => true,
|
||||
'is_suspended' => false,
|
||||
@@ -123,6 +122,7 @@ class RegisteredUserController extends Controller
|
||||
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
|
||||
'price' => 0,
|
||||
'purchased_at' => now(),
|
||||
'provider' => 'free',
|
||||
'provider_id' => 'free',
|
||||
]);
|
||||
|
||||
@@ -146,8 +146,3 @@ class RegisteredUserController extends Controller
|
||||
return Inertia::location(route('verification.notice'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,22 +7,16 @@ use App\Models\AbandonedCheckout;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Http\Controllers\Auth\AuthenticatedSessionController;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Support\Str;
|
||||
use Stripe\PaymentIntent;
|
||||
use Stripe\Stripe;
|
||||
|
||||
use App\Http\Controllers\PayPalController;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
|
||||
class CheckoutController extends Controller
|
||||
{
|
||||
@@ -32,6 +26,7 @@ class CheckoutController extends Controller
|
||||
{
|
||||
$googleStatus = session()->pull('checkout_google_status');
|
||||
$googleError = session()->pull('checkout_google_error');
|
||||
$googleProfile = session()->pull('checkout_google_profile');
|
||||
|
||||
$packageOptions = Package::orderBy('price')->get()
|
||||
->map(fn (Package $pkg) => $this->presentPackage($pkg))
|
||||
@@ -41,8 +36,6 @@ class CheckoutController extends Controller
|
||||
return Inertia::render('marketing/CheckoutWizardPage', [
|
||||
'package' => $this->presentPackage($package),
|
||||
'packageOptions' => $packageOptions,
|
||||
'stripePublishableKey' => config('services.stripe.key'),
|
||||
'paypalClientId' => config('services.paypal.client_id'),
|
||||
'privacyHtml' => view('legal.datenschutz-partial')->render(),
|
||||
'auth' => [
|
||||
'user' => Auth::user(),
|
||||
@@ -50,6 +43,11 @@ class CheckoutController extends Controller
|
||||
'googleAuth' => [
|
||||
'status' => $googleStatus,
|
||||
'error' => $googleError,
|
||||
'profile' => $googleProfile,
|
||||
],
|
||||
'paddle' => [
|
||||
'environment' => config('paddle.environment'),
|
||||
'client_token' => config('paddle.client_token'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -58,9 +56,16 @@ class CheckoutController extends Controller
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'username' => 'required|string|max:255|unique:users,username',
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'first_name' => 'required|string|max:255',
|
||||
'last_name' => 'required|string|max:255',
|
||||
'address' => 'required|string|max:500',
|
||||
'phone' => 'required|string|max:255',
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'terms' => 'required|accepted',
|
||||
'privacy_consent' => 'required|accepted',
|
||||
'locale' => 'nullable|string|max:10',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@@ -72,43 +77,50 @@ class CheckoutController extends Controller
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
$validated = $validator->validated();
|
||||
DB::transaction(function () use ($request, $package, $validated) {
|
||||
|
||||
|
||||
// User erstellen
|
||||
$user = User::create([
|
||||
'email' => $request->email,
|
||||
'username' => $validated['username'],
|
||||
'first_name' => $validated['first_name'],
|
||||
'last_name' => $validated['last_name'],
|
||||
'name' => trim($validated['first_name'].' '.$validated['last_name']),
|
||||
'address' => $validated['address'],
|
||||
'phone' => $validated['phone'],
|
||||
'preferred_locale' => $validated['locale'] ?? null,
|
||||
'password' => Hash::make($request->password),
|
||||
'pending_purchase' => true,
|
||||
]);
|
||||
|
||||
// Tenant erstellen
|
||||
$tenant = Tenant::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $validated['first_name'] . ' ' . $validated['last_name'],
|
||||
'slug' => Str::slug($validated['first_name'] . ' ' . $validated['last_name'] . '-' . now()->timestamp),
|
||||
'email' => $validated['email'],
|
||||
'is_active' => true,
|
||||
'is_suspended' => false,
|
||||
'event_credits_balance' => 0,
|
||||
'subscription_tier' => 'free',
|
||||
'subscription_expires_at' => null,
|
||||
'settings' => json_encode([
|
||||
'branding' => [
|
||||
'logo_url' => null,
|
||||
'primary_color' => '#3B82F6',
|
||||
'secondary_color' => '#1F2937',
|
||||
'font_family' => 'Inter, sans-serif',
|
||||
],
|
||||
'features' => [
|
||||
'photo_likes_enabled' => false,
|
||||
'event_checklist' => false,
|
||||
'custom_domain' => false,
|
||||
'advanced_analytics' => false,
|
||||
],
|
||||
'custom_domain' => null,
|
||||
'contact_email' => $validated['email'],
|
||||
'event_default_type' => 'general',
|
||||
]),
|
||||
]);
|
||||
'user_id' => $user->id,
|
||||
'name' => $validated['first_name'].' '.$validated['last_name'],
|
||||
'slug' => Str::slug($validated['first_name'].' '.$validated['last_name'].'-'.now()->timestamp),
|
||||
'email' => $validated['email'],
|
||||
'is_active' => true,
|
||||
'is_suspended' => false,
|
||||
'event_credits_balance' => 0,
|
||||
'subscription_tier' => 'free',
|
||||
'subscription_expires_at' => null,
|
||||
'settings' => json_encode([
|
||||
'branding' => [
|
||||
'logo_url' => null,
|
||||
'primary_color' => '#3B82F6',
|
||||
'secondary_color' => '#1F2937',
|
||||
'font_family' => 'Inter, sans-serif',
|
||||
],
|
||||
'features' => [
|
||||
'photo_likes_enabled' => false,
|
||||
'event_checklist' => false,
|
||||
'custom_domain' => false,
|
||||
'advanced_analytics' => false,
|
||||
],
|
||||
'custom_domain' => null,
|
||||
'contact_email' => $validated['email'],
|
||||
'event_default_type' => 'general',
|
||||
]),
|
||||
]);
|
||||
|
||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
||||
// Package zuweisen
|
||||
@@ -151,12 +163,12 @@ class CheckoutController extends Controller
|
||||
// Custom Auth für Identifier (E-Mail oder Username)
|
||||
$identifier = $request->identifier;
|
||||
$user = User::where('email', $identifier)
|
||||
->orWhere('username', $identifier)
|
||||
->first();
|
||||
->orWhere('username', $identifier)
|
||||
->first();
|
||||
|
||||
if (!$user || !Hash::check($request->password, $user->password)) {
|
||||
if (! $user || ! Hash::check($request->password, $user->password)) {
|
||||
return response()->json([
|
||||
'errors' => ['identifier' => ['Ungültige Anmeldedaten.']]
|
||||
'errors' => ['identifier' => ['Ungültige Anmeldedaten.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
@@ -165,7 +177,7 @@ class CheckoutController extends Controller
|
||||
|
||||
// Checkout-spezifische Logik
|
||||
DB::transaction(function () use ($request, $user, $packageId) {
|
||||
if ($packageId && !$user->pending_purchase) {
|
||||
if ($packageId && ! $user->pending_purchase) {
|
||||
$user->update(['pending_purchase' => true]);
|
||||
$request->session()->put('pending_package_id', $packageId);
|
||||
}
|
||||
@@ -242,165 +254,6 @@ class CheckoutController extends Controller
|
||||
return response()->json(['status' => 'tracked']);
|
||||
}
|
||||
|
||||
public function createPaymentIntent(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
]);
|
||||
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
|
||||
\Log::info('Create Payment Intent', [
|
||||
'package_id' => $package->id,
|
||||
'package_name' => $package->name,
|
||||
'price' => $package->price,
|
||||
'is_free' => $package->is_free,
|
||||
'user_id' => Auth::id(),
|
||||
]);
|
||||
|
||||
$isFreePackage = $this->packageIsFree($package);
|
||||
|
||||
if ($isFreePackage) {
|
||||
\Log::info('Free package detected, returning null client_secret');
|
||||
return response()->json([
|
||||
'client_secret' => null,
|
||||
'free_package' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// Stripe API Key setzen
|
||||
Stripe::setApiKey(config('services.stripe.secret'));
|
||||
|
||||
try {
|
||||
$paymentIntent = PaymentIntent::create([
|
||||
'amount' => $package->price * 100, // Stripe erwartet Cent
|
||||
'currency' => 'eur',
|
||||
'metadata' => [
|
||||
'package_id' => $package->id,
|
||||
'user_id' => Auth::id(),
|
||||
],
|
||||
]);
|
||||
|
||||
\Log::info('PaymentIntent created successfully', [
|
||||
'payment_intent_id' => $paymentIntent->id,
|
||||
'client_secret' => substr($paymentIntent->client_secret, 0, 50) . '...',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'client_secret' => $paymentIntent->client_secret,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Stripe PaymentIntent creation failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Fehler beim Erstellen der Zahlungsdaten: ' . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmPayment(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'payment_intent_id' => 'required|string',
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
]);
|
||||
|
||||
// Stripe API Key setzen
|
||||
Stripe::setApiKey(config('services.stripe.secret'));
|
||||
|
||||
$paymentIntent = PaymentIntent::retrieve($request->payment_intent_id);
|
||||
|
||||
if ($paymentIntent->status !== 'succeeded') {
|
||||
return response()->json([
|
||||
'error' => 'Zahlung nicht erfolgreich.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
$user = Auth::user();
|
||||
|
||||
// Package dem Tenant zuweisen
|
||||
$user->tenant->packages()->attach($package->id, [
|
||||
'price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
// pending_purchase zurücksetzen
|
||||
$user->update(['pending_purchase' => false]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Zahlung erfolgreich bestätigt.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function handlePayPalReturn(Request $request)
|
||||
{
|
||||
$orderId = $request->query('orderID');
|
||||
|
||||
if (!$orderId) {
|
||||
return redirect('/checkout')->with('error', 'Ungültige PayPal-Rückkehr.');
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user) {
|
||||
return redirect('/login')->with('error', 'Bitte melden Sie sich an.');
|
||||
}
|
||||
|
||||
try {
|
||||
// Capture aufrufen
|
||||
$paypalController = new PayPalController();
|
||||
$captureRequest = new Request(['order_id' => $orderId]);
|
||||
$captureResponse = $paypalController->captureOrder($captureRequest);
|
||||
|
||||
if ($captureResponse->getStatusCode() !== 200 || !isset($captureResponse->getData(true)['status']) || $captureResponse->getData(true)['status'] !== 'captured') {
|
||||
Log::error('PayPal capture failed in return handler', ['order_id' => $orderId, 'response' => $captureResponse->getData(true)]);
|
||||
return redirect('/checkout')->with('error', 'Zahlung konnte nicht abgeschlossen werden.');
|
||||
}
|
||||
|
||||
// PackagePurchase finden (erzeugt durch captureOrder)
|
||||
$purchase = \App\Models\PackagePurchase::where('provider_id', $orderId)
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if (!$purchase) {
|
||||
Log::error('No PackagePurchase found after PayPal capture', ['order_id' => $orderId, 'tenant_id' => $user->tenant_id]);
|
||||
return redirect('/checkout')->with('error', 'Kauf konnte nicht verifiziert werden.');
|
||||
}
|
||||
|
||||
$package = \App\Models\Package::find($purchase->package_id);
|
||||
|
||||
if (!$package) {
|
||||
return redirect('/checkout')->with('error', 'Paket nicht gefunden.');
|
||||
}
|
||||
|
||||
// TenantPackage zuweisen (ähnlich Stripe)
|
||||
$user->tenant->packages()->attach($package->id, [
|
||||
'price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
// pending_purchase zurücksetzen
|
||||
$user->update(['pending_purchase' => false]);
|
||||
|
||||
Log::info('PayPal payment completed and package assigned', ['order_id' => $orderId, 'package_id' => $package->id, 'tenant_id' => $user->tenant_id]);
|
||||
|
||||
return redirect('/success/' . $package->id)->with('success', 'Zahlung erfolgreich! Ihr Paket wurde aktiviert.');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error in PayPal return handler', ['order_id' => $orderId, 'error' => $e->getMessage()]);
|
||||
return redirect('/checkout')->with('error', 'Fehler beim Abschließen der Zahlung: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function packageIsFree(Package $package): bool
|
||||
{
|
||||
if (isset($package->is_free)) {
|
||||
|
||||
@@ -2,17 +2,13 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Mail\Welcome;
|
||||
use App\Models\Package;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
@@ -52,56 +48,56 @@ class CheckoutGoogleController extends Controller
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Google checkout login failed', ['message' => $e->getMessage()]);
|
||||
$this->flashError($request, __('checkout.google_error_fallback'));
|
||||
|
||||
return $this->redirectBackToWizard($packageId);
|
||||
}
|
||||
|
||||
$email = $googleUser->getEmail();
|
||||
if (! $email) {
|
||||
$this->flashError($request, __('checkout.google_missing_email'));
|
||||
|
||||
return $this->redirectBackToWizard($packageId);
|
||||
}
|
||||
|
||||
$user = DB::transaction(function () use ($googleUser, $email) {
|
||||
$existing = User::where('email', $email)->first();
|
||||
$raw = $googleUser->getRaw();
|
||||
$request->session()->put('checkout_google_profile', array_filter([
|
||||
'email' => $email,
|
||||
'name' => $googleUser->getName(),
|
||||
'given_name' => $raw['given_name'] ?? null,
|
||||
'family_name' => $raw['family_name'] ?? null,
|
||||
'avatar' => $googleUser->getAvatar(),
|
||||
'locale' => $raw['locale'] ?? null,
|
||||
]));
|
||||
|
||||
if ($existing) {
|
||||
$existing->forceFill([
|
||||
'name' => $googleUser->getName() ?: $existing->name,
|
||||
'pending_purchase' => true,
|
||||
'email_verified_at' => $existing->email_verified_at ?? now(),
|
||||
])->save();
|
||||
$existing = User::where('email', $email)->first();
|
||||
|
||||
if (! $existing->tenant) {
|
||||
$this->createTenantForUser($existing, $googleUser->getName(), $email);
|
||||
}
|
||||
|
||||
return $existing->fresh();
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'name' => $googleUser->getName(),
|
||||
if (! $existing) {
|
||||
$request->session()->put('checkout_google_profile', array_filter([
|
||||
'email' => $email,
|
||||
'password' => Hash::make(Str::random(32)),
|
||||
'name' => $googleUser->getName(),
|
||||
'given_name' => $raw['given_name'] ?? null,
|
||||
'family_name' => $raw['family_name'] ?? null,
|
||||
'avatar' => $googleUser->getAvatar(),
|
||||
'locale' => $raw['locale'] ?? null,
|
||||
]));
|
||||
|
||||
$request->session()->put('checkout_google_status', 'prefill');
|
||||
|
||||
return $this->redirectBackToWizard($packageId);
|
||||
}
|
||||
|
||||
$user = DB::transaction(function () use ($existing, $googleUser, $email) {
|
||||
$existing->forceFill([
|
||||
'name' => $googleUser->getName() ?: $existing->name,
|
||||
'pending_purchase' => true,
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
'email_verified_at' => $existing->email_verified_at ?? now(),
|
||||
])->save();
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
$tenant = $this->createTenantForUser($user, $googleUser->getName(), $email);
|
||||
|
||||
try {
|
||||
Mail::to($user)
|
||||
->locale($user->preferred_locale ?? app()->getLocale())
|
||||
->queue(new Welcome($user));
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Failed to queue welcome mail after Google signup', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
if (! $existing->tenant) {
|
||||
$this->createTenantForUser($existing, $googleUser->getName(), $email);
|
||||
}
|
||||
|
||||
return tap($user)->setRelation('tenant', $tenant);
|
||||
return $existing->fresh();
|
||||
});
|
||||
|
||||
if (! $user->tenant) {
|
||||
@@ -111,7 +107,8 @@ class CheckoutGoogleController extends Controller
|
||||
Auth::login($user, true);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->forget(self::SESSION_KEY);
|
||||
$request->session()->put('checkout_google_status', 'success');
|
||||
$request->session()->forget('checkout_google_profile');
|
||||
$request->session()->put('checkout_google_status', 'signin');
|
||||
|
||||
if ($packageId) {
|
||||
$this->ensurePackageAttached($user, (int) $packageId);
|
||||
@@ -128,7 +125,7 @@ class CheckoutGoogleController extends Controller
|
||||
$counter = 1;
|
||||
|
||||
while (Tenant::where('slug', $slug)->exists()) {
|
||||
$slug = $slugBase . '-' . $counter;
|
||||
$slug = $slugBase.'-'.$counter;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,42 +3,36 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Mail\ContactConfirmation;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Stripe\Stripe;
|
||||
use Stripe\StripeClient;
|
||||
use Exception;
|
||||
use PayPalHttp\Client;
|
||||
use PayPalHttp\HttpException;
|
||||
use PayPalCheckout\OrdersCreateRequest;
|
||||
use PayPalCheckout\OrdersCaptureRequest;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\BlogPost;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\Extension\Table\TableExtension;
|
||||
use League\CommonMark\Extension\Autolink\AutolinkExtension;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
|
||||
use League\CommonMark\Extension\Table\TableExtension;
|
||||
use League\CommonMark\Extension\TaskList\TaskListExtension;
|
||||
use League\CommonMark\MarkdownConverter;
|
||||
use App\Support\Concerns\PresentsPackages;
|
||||
|
||||
class MarketingController extends Controller
|
||||
{
|
||||
use PresentsPackages;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
Stripe::setApiKey(config('services.stripe.key'));
|
||||
}
|
||||
public function __construct(
|
||||
private readonly CheckoutSessionService $checkoutSessions,
|
||||
private readonly PaddleCheckoutService $paddleCheckout,
|
||||
) {}
|
||||
|
||||
public function index()
|
||||
{
|
||||
@@ -69,7 +63,7 @@ class MarketingController extends Controller
|
||||
'email' => $request->email,
|
||||
'message' => $request->message,
|
||||
], $locale),
|
||||
function ($message) use ($request, $contactAddress, $locale) {
|
||||
function ($message) use ($contactAddress, $locale) {
|
||||
$message->to($contactAddress)
|
||||
->subject(__('emails.contact.subject', [], $locale));
|
||||
}
|
||||
@@ -94,22 +88,22 @@ class MarketingController extends Controller
|
||||
*/
|
||||
public function buyPackages(Request $request, $packageId)
|
||||
{
|
||||
Log::info('Buy packages called', ['auth' => Auth::check(), 'package_id' => $packageId, 'provider' => $request->input('provider', 'stripe')]);
|
||||
Log::info('Buy packages called', ['auth' => Auth::check(), 'package_id' => $packageId]);
|
||||
$package = Package::findOrFail($packageId);
|
||||
|
||||
if (!Auth::check()) {
|
||||
if (! Auth::check()) {
|
||||
return redirect()->route('register', ['package_id' => $package->id])
|
||||
->with('message', __('marketing.packages.register_required'));
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
if (!$user->email_verified_at) {
|
||||
if (! $user->email_verified_at) {
|
||||
return redirect()->route('verification.notice')
|
||||
->with('message', __('auth.verification_required'));
|
||||
}
|
||||
|
||||
$tenant = $user->tenant;
|
||||
if (!$tenant) {
|
||||
if (! $tenant) {
|
||||
abort(500, 'Tenant not found');
|
||||
}
|
||||
|
||||
@@ -130,6 +124,7 @@ class MarketingController extends Controller
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'provider' => 'free',
|
||||
'provider_id' => 'free',
|
||||
'price' => $package->price,
|
||||
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
|
||||
@@ -140,246 +135,49 @@ class MarketingController extends Controller
|
||||
return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned'));
|
||||
}
|
||||
|
||||
if ($package->type === 'reseller') {
|
||||
return $this->stripeSubscription($request, $packageId);
|
||||
if (! $package->paddle_price_id) {
|
||||
Log::warning('Package missing Paddle price id', ['package_id' => $package->id]);
|
||||
|
||||
return redirect()->route('packages', ['highlight' => $package->slug])
|
||||
->with('error', __('marketing.packages.paddle_not_configured'));
|
||||
}
|
||||
|
||||
if ($request->input('provider') === 'paypal') {
|
||||
return $this->paypalCheckout($request, $packageId);
|
||||
}
|
||||
$session = $this->checkoutSessions->createOrResume($user, $package, [
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
|
||||
return $this->checkout($request, $packageId);
|
||||
}
|
||||
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Checkout for Stripe with auth metadata.
|
||||
*/
|
||||
public function checkout(Request $request, $packageId)
|
||||
{
|
||||
$package = Package::findOrFail($packageId);
|
||||
$user = Auth::user();
|
||||
$tenant = $user->tenant;
|
||||
|
||||
$stripe = new StripeClient(config('services.stripe.secret'));
|
||||
$session = $stripe->checkout->sessions->create([
|
||||
'payment_method_types' => ['card'],
|
||||
'line_items' => [[
|
||||
'price_data' => [
|
||||
'currency' => 'eur',
|
||||
'product_data' => [
|
||||
'name' => $package->name,
|
||||
],
|
||||
'unit_amount' => $package->price * 100,
|
||||
],
|
||||
'quantity' => 1,
|
||||
]],
|
||||
'mode' => 'payment',
|
||||
'success_url' => route('marketing.success', $packageId),
|
||||
'cancel_url' => route('packages'),
|
||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
|
||||
'success_url' => route('marketing.success', ['packageId' => $package->id]),
|
||||
'return_url' => route('packages', ['highlight' => $package->slug]),
|
||||
'metadata' => [
|
||||
'user_id' => $user->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'type' => $package->type,
|
||||
'checkout_session_id' => $session->id,
|
||||
],
|
||||
]);
|
||||
|
||||
Log::info('Stripe Checkout initiated', ['package_id' => $packageId, 'session_id' => $session->id, 'tenant_id' => $tenant->id]);
|
||||
$session->forceFill([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||
])),
|
||||
])->save();
|
||||
|
||||
return redirect($session->url, 303);
|
||||
}
|
||||
$redirectUrl = $checkout['checkout_url'] ?? null;
|
||||
|
||||
/**
|
||||
* PayPal checkout with v2 Orders API (one-time payment).
|
||||
*/
|
||||
public function paypalCheckout(Request $request, $packageId)
|
||||
{
|
||||
$package = Package::findOrFail($packageId);
|
||||
$user = Auth::user();
|
||||
$tenant = $user->tenant;
|
||||
|
||||
$client = Client::create([
|
||||
'clientId' => config('services.paypal.client_id'),
|
||||
'clientSecret' => config('services.paypal.secret'),
|
||||
'environment' => config('services.paypal.sandbox', true) ? 'sandbox' : 'live',
|
||||
]);
|
||||
|
||||
$ordersController = $client->orders();
|
||||
|
||||
$metadata = json_encode([
|
||||
'user_id' => $user->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'type' => $package->type,
|
||||
]);
|
||||
|
||||
$createRequest = new OrdersCreateRequest();
|
||||
$createRequest->prefer('return=representation');
|
||||
$createRequest->body = [
|
||||
"intent" => "CAPTURE",
|
||||
"purchase_units" => [[
|
||||
"amount" => [
|
||||
"currency_code" => "EUR",
|
||||
"value" => number_format($package->price, 2, '.', ''),
|
||||
],
|
||||
"description" => "Package: " . $package->name,
|
||||
"custom_id" => $metadata,
|
||||
]],
|
||||
"application_context" => [
|
||||
"return_url" => route('marketing.success', $packageId),
|
||||
"cancel_url" => route('packages'),
|
||||
],
|
||||
];
|
||||
|
||||
try {
|
||||
$response = $ordersController->createOrder($createRequest);
|
||||
$order = $response->result;
|
||||
|
||||
Log::info('PayPal Checkout initiated', ['package_id' => $packageId, 'order_id' => $order->id, 'tenant_id' => $tenant->id]);
|
||||
|
||||
session(['paypal_order_id' => $order->id]);
|
||||
|
||||
foreach ($order->links as $link) {
|
||||
if ($link->rel === 'approve') {
|
||||
return redirect($link->href);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Exception('No approve link found');
|
||||
} catch (HttpException $e) {
|
||||
Log::error('PayPal Orders API error: ' . $e->getMessage());
|
||||
return back()->with('error', 'Zahlung fehlgeschlagen');
|
||||
} catch (Exception $e) {
|
||||
Log::error('PayPal checkout error: ' . $e->getMessage());
|
||||
return back()->with('error', 'Zahlung fehlgeschlagen');
|
||||
if (! $redirectUrl) {
|
||||
throw ValidationException::withMessages([
|
||||
'paddle' => __('marketing.packages.paddle_checkout_failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->away($redirectUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe subscription checkout for reseller packages.
|
||||
*/
|
||||
public function stripeSubscription(Request $request, $packageId)
|
||||
{
|
||||
$package = Package::findOrFail($packageId);
|
||||
$user = Auth::user();
|
||||
$tenant = $user->tenant;
|
||||
|
||||
$stripe = new StripeClient(config('services.stripe.secret'));
|
||||
$session = $stripe->checkout->sessions->create([
|
||||
'payment_method_types' => ['card'],
|
||||
'line_items' => [[
|
||||
'price_data' => [
|
||||
'currency' => 'eur',
|
||||
'product_data' => [
|
||||
'name' => $package->name . ' (Annual Subscription)',
|
||||
],
|
||||
'unit_amount' => $package->price * 100,
|
||||
'recurring' => [
|
||||
'interval' => 'year',
|
||||
'interval_count' => 1,
|
||||
],
|
||||
],
|
||||
'quantity' => 1,
|
||||
]],
|
||||
'mode' => 'subscription',
|
||||
'success_url' => route('marketing.success', $packageId),
|
||||
'cancel_url' => route('packages'),
|
||||
'metadata' => [
|
||||
'user_id' => $user->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'type' => $package->type,
|
||||
'subscription' => 'true',
|
||||
],
|
||||
]);
|
||||
|
||||
return redirect($session->url, 303);
|
||||
}
|
||||
|
||||
public function stripeCheckout($sessionId)
|
||||
{
|
||||
// Handle Stripe success
|
||||
return view('marketing.success', ['provider' => 'Stripe']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle success after payment (capture PayPal, redirect if verified).
|
||||
*/
|
||||
public function success(Request $request, $packageId = null)
|
||||
{
|
||||
$provider = session('paypal_order_id') ? 'paypal' : 'stripe';
|
||||
Log::info('Payment Success: Provider processed', ['provider' => $provider, 'package_id' => $packageId]);
|
||||
|
||||
if (session('paypal_order_id')) {
|
||||
$orderId = session('paypal_order_id');
|
||||
$client = Client::create([
|
||||
'clientId' => config('services.paypal.client_id'),
|
||||
'clientSecret' => config('services.paypal.secret'),
|
||||
'environment' => config('services.paypal.sandbox', true) ? 'sandbox' : 'live',
|
||||
]);
|
||||
|
||||
$ordersController = $client->orders();
|
||||
|
||||
$captureRequest = new OrdersCaptureRequest($orderId);
|
||||
$captureRequest->prefer('return=minimal');
|
||||
|
||||
try {
|
||||
$captureResponse = $ordersController->captureOrder($captureRequest);
|
||||
$capture = $captureResponse->result;
|
||||
|
||||
Log::info('PayPal Capture completed', ['order_id' => $orderId, 'status' => $capture->status]);
|
||||
|
||||
if ($capture->status === 'COMPLETED') {
|
||||
$customId = $capture->purchaseUnits[0]->customId ?? null;
|
||||
if ($customId) {
|
||||
$metadata = json_decode($customId, true);
|
||||
$package = Package::find($metadata['package_id']);
|
||||
$tenant = Tenant::find($metadata['tenant_id']);
|
||||
|
||||
if ($package && $tenant) {
|
||||
TenantPackage::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
],
|
||||
[
|
||||
'price' => $package->price,
|
||||
'active' => true,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => now()->addYear(), // One-time as annual for reseller too
|
||||
]
|
||||
);
|
||||
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'provider_id' => 'paypal',
|
||||
'price' => $package->price,
|
||||
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
|
||||
'purchased_at' => now(),
|
||||
'refunded' => false,
|
||||
]);
|
||||
|
||||
session()->forget('paypal_order_id');
|
||||
$request->session()->flash('success', __('marketing.packages.purchased_successfully', ['name' => $package->name]));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log::error('PayPal capture failed: ' . $capture->status);
|
||||
$request->session()->flash('error', 'Zahlung konnte nicht abgeschlossen werden.');
|
||||
}
|
||||
} catch (HttpException $e) {
|
||||
Log::error('PayPal capture error: ' . $e->getMessage());
|
||||
$request->session()->flash('error', 'Zahlung konnte nicht abgeschlossen werden.');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('PayPal success error: ' . $e->getMessage());
|
||||
$request->session()->flash('error', 'Fehler beim Abschliessen der Zahlung.');
|
||||
}
|
||||
}
|
||||
|
||||
// Common logic: Redirect to admin if verified
|
||||
if (Auth::check() && Auth::user()->email_verified_at) {
|
||||
return redirect('/event-admin')->with('success', __('marketing.success.welcome'));
|
||||
}
|
||||
@@ -392,7 +190,7 @@ class MarketingController extends Controller
|
||||
$locale = $request->get('locale', app()->getLocale());
|
||||
Log::info('Blog Index Debug - Initial', [
|
||||
'locale' => $locale,
|
||||
'full_url' => $request->fullUrl()
|
||||
'full_url' => $request->fullUrl(),
|
||||
]);
|
||||
|
||||
$query = BlogPost::query()
|
||||
@@ -424,6 +222,7 @@ class MarketingController extends Controller
|
||||
$post->title = $post->getTranslation('title', $locale) ?? $post->getTranslation('title', 'de') ?? '';
|
||||
$post->excerpt = $post->getTranslation('excerpt', $locale) ?? $post->getTranslation('excerpt', 'de') ?? '';
|
||||
$post->content = $post->getTranslation('content', $locale) ?? $post->getTranslation('content', 'de') ?? '';
|
||||
|
||||
// Author name is a string, no translation needed; author is loaded via with('author')
|
||||
return $post;
|
||||
});
|
||||
@@ -432,7 +231,7 @@ class MarketingController extends Controller
|
||||
'count' => $posts->count(),
|
||||
'total' => $posts->total(),
|
||||
'posts_data' => $posts->toArray(),
|
||||
'first_post_title' => $posts->count() > 0 ? $posts->first()->title : 'No posts'
|
||||
'first_post_title' => $posts->count() > 0 ? $posts->first()->title : 'No posts',
|
||||
]);
|
||||
|
||||
return Inertia::render('marketing/Blog', compact('posts'));
|
||||
@@ -456,24 +255,24 @@ class MarketingController extends Controller
|
||||
// Transform to array with translated strings for the current locale
|
||||
$markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? '';
|
||||
|
||||
$environment = new Environment();
|
||||
$environment->addExtension(new CommonMarkCoreExtension());
|
||||
$environment->addExtension(new TableExtension());
|
||||
$environment->addExtension(new AutolinkExtension());
|
||||
$environment->addExtension(new StrikethroughExtension());
|
||||
$environment->addExtension(new TaskListExtension());
|
||||
$environment = new Environment;
|
||||
$environment->addExtension(new CommonMarkCoreExtension);
|
||||
$environment->addExtension(new TableExtension);
|
||||
$environment->addExtension(new AutolinkExtension);
|
||||
$environment->addExtension(new StrikethroughExtension);
|
||||
$environment->addExtension(new TaskListExtension);
|
||||
|
||||
$converter = new MarkdownConverter($environment);
|
||||
$contentHtml = (string) $converter->convert($markdown);
|
||||
|
||||
|
||||
// Debug log for content_html
|
||||
\Log::info('BlogShow Debug: content_html type and preview', [
|
||||
'type' => gettype($contentHtml),
|
||||
'is_string' => is_string($contentHtml),
|
||||
'length' => strlen($contentHtml ?? ''),
|
||||
'preview' => substr((string)$contentHtml, 0, 200) . '...'
|
||||
'preview' => substr((string) $contentHtml, 0, 200).'...',
|
||||
]);
|
||||
|
||||
|
||||
$post = [
|
||||
'id' => $postModel->id,
|
||||
'title' => $postModel->getTranslation('title', $locale) ?? $postModel->getTranslation('title', 'de') ?? '',
|
||||
@@ -484,17 +283,17 @@ class MarketingController extends Controller
|
||||
'published_at' => $postModel->published_at->toDateString(),
|
||||
'slug' => $postModel->slug,
|
||||
'author' => $postModel->author ? [
|
||||
'name' => $postModel->author->name
|
||||
'name' => $postModel->author->name,
|
||||
] : null,
|
||||
];
|
||||
|
||||
|
||||
// Debug log for final postArray
|
||||
\Log::info('BlogShow Debug: Final post content_html', [
|
||||
'type' => gettype($post['content_html']),
|
||||
'is_string' => is_string($post['content_html']),
|
||||
'length' => strlen($post['content_html'] ?? ''),
|
||||
]);
|
||||
|
||||
|
||||
return Inertia::render('marketing/BlogShow', compact('post'));
|
||||
}
|
||||
|
||||
@@ -527,11 +326,11 @@ class MarketingController extends Controller
|
||||
'locale' => app()->getLocale(),
|
||||
'url' => request()->fullUrl(),
|
||||
'route' => request()->route()->getName(),
|
||||
'isInertia' => request()->header('X-Inertia')
|
||||
'isInertia' => request()->header('X-Inertia'),
|
||||
]);
|
||||
|
||||
$validTypes = ['hochzeit', 'geburtstag', 'firmenevent'];
|
||||
if (!in_array($type, $validTypes)) {
|
||||
if (! in_array($type, $validTypes)) {
|
||||
Log::warning('Invalid occasion type accessed', ['type' => $type]);
|
||||
abort(404, 'Invalid occasion type');
|
||||
}
|
||||
|
||||
97
app/Http/Controllers/PaddleCheckoutController.php
Normal file
97
app/Http/Controllers/PaddleCheckoutController.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PaddleCheckoutController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PaddleCheckoutService $checkout,
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
) {}
|
||||
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'package_id' => ['required', 'exists:packages,id'],
|
||||
'success_url' => ['nullable', 'url'],
|
||||
'return_url' => ['nullable', 'url'],
|
||||
'inline' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
$tenant = $user?->tenant;
|
||||
|
||||
if (! $tenant) {
|
||||
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
|
||||
}
|
||||
|
||||
$package = Package::findOrFail((int) $data['package_id']);
|
||||
|
||||
if (! $package->paddle_price_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||
}
|
||||
|
||||
$session = $this->sessions->createOrResume($user, $package, [
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
if ($request->boolean('inline')) {
|
||||
$metadata = array_merge($session->provider_metadata ?? [], [
|
||||
'mode' => 'inline',
|
||||
]);
|
||||
|
||||
$session->forceFill([
|
||||
'provider_metadata' => $metadata,
|
||||
])->save();
|
||||
|
||||
return response()->json([
|
||||
'mode' => 'inline',
|
||||
'items' => [
|
||||
[
|
||||
'priceId' => $package->paddle_price_id,
|
||||
'quantity' => 1,
|
||||
],
|
||||
],
|
||||
'custom_data' => [
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'package_id' => (string) $package->id,
|
||||
'checkout_session_id' => (string) $session->id,
|
||||
],
|
||||
'customer' => array_filter([
|
||||
'email' => $user->email,
|
||||
'name' => trim(($user->first_name ?? '').' '.($user->last_name ?? '')) ?: ($user->name ?? null),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
$checkout = $this->checkout->createCheckout($tenant, $package, [
|
||||
'success_url' => $data['success_url'] ?? null,
|
||||
'return_url' => $data['return_url'] ?? null,
|
||||
'metadata' => [
|
||||
'checkout_session_id' => $session->id,
|
||||
],
|
||||
]);
|
||||
|
||||
$session->forceFill([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||
])),
|
||||
])->save();
|
||||
|
||||
return response()->json($checkout);
|
||||
}
|
||||
}
|
||||
68
app/Http/Controllers/PaddleWebhookController.php
Normal file
68
app/Http/Controllers/PaddleWebhookController.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Checkout\CheckoutWebhookService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PaddleWebhookController extends Controller
|
||||
{
|
||||
public function __construct(private readonly CheckoutWebhookService $webhooks) {}
|
||||
|
||||
public function handle(Request $request): JsonResponse
|
||||
{
|
||||
if (! $this->verify($request)) {
|
||||
Log::warning('Paddle webhook signature verification failed');
|
||||
|
||||
return response()->json(['status' => 'invalid'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$payload = $request->json()->all();
|
||||
|
||||
if (! is_array($payload)) {
|
||||
return response()->json(['status' => 'ignored'], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
|
||||
$eventType = $payload['event_type'] ?? null;
|
||||
$handled = false;
|
||||
|
||||
if ($eventType) {
|
||||
$handled = $this->webhooks->handlePaddleEvent($payload);
|
||||
}
|
||||
|
||||
Log::info('Paddle webhook processed', [
|
||||
'event_type' => $eventType,
|
||||
'handled' => $handled,
|
||||
]);
|
||||
|
||||
$statusCode = $handled ? Response::HTTP_OK : Response::HTTP_ACCEPTED;
|
||||
|
||||
return response()->json([
|
||||
'status' => $handled ? 'processed' : 'ignored',
|
||||
], $statusCode);
|
||||
}
|
||||
|
||||
protected function verify(Request $request): bool
|
||||
{
|
||||
$secret = config('paddle.webhook_secret');
|
||||
|
||||
if (! $secret) {
|
||||
// Allow processing in sandbox or when secret not configured
|
||||
return true;
|
||||
}
|
||||
|
||||
$signature = (string) $request->headers->get('Paddle-Webhook-Signature', '');
|
||||
|
||||
if ($signature === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = $request->getContent();
|
||||
$expected = hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
return hash_equals($expected, $signature);
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\Package;
|
||||
|
||||
use PaypalServerSdkLib\Models\Builders\OrderRequestBuilder;
|
||||
use PaypalServerSdkLib\Models\Builders\PurchaseUnitRequestBuilder;
|
||||
use PaypalServerSdkLib\Models\Builders\AmountWithBreakdownBuilder;
|
||||
use PaypalServerSdkLib\Models\Builders\OrderApplicationContextBuilder;
|
||||
use PaypalServerSdkLib\Models\CheckoutPaymentIntent;
|
||||
use App\Services\PayPal\PaypalClientFactory;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class PayPalController extends Controller
|
||||
{
|
||||
private $client;
|
||||
private PaypalClientFactory $clientFactory;
|
||||
|
||||
public function __construct(PaypalClientFactory $clientFactory)
|
||||
{
|
||||
$this->clientFactory = $clientFactory;
|
||||
$this->client = $clientFactory->make();
|
||||
}
|
||||
|
||||
public function createOrder(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'tenant_id' => 'nullable|exists:tenants,id',
|
||||
]);
|
||||
|
||||
$tenant = $request->tenant_id
|
||||
? Tenant::findOrFail($request->tenant_id)
|
||||
: optional(Auth::user())->tenant;
|
||||
|
||||
if (! $tenant) {
|
||||
return response()->json(['error' => 'Tenant context required for checkout.'], 422);
|
||||
}
|
||||
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
|
||||
$ordersController = $this->client->getOrdersController();
|
||||
|
||||
$body = OrderRequestBuilder::init(
|
||||
CheckoutPaymentIntent::CAPTURE,
|
||||
[
|
||||
PurchaseUnitRequestBuilder::init(
|
||||
AmountWithBreakdownBuilder::init('EUR', number_format($package->price, 2, '.', ''))
|
||||
->build()
|
||||
)
|
||||
->description('Package: ' . $package->name)
|
||||
->customId(json_encode([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'type' => 'endcustomer_event'
|
||||
]))
|
||||
->build()
|
||||
]
|
||||
)
|
||||
->applicationContext(
|
||||
OrderApplicationContextBuilder::init()
|
||||
->brandName('Fotospiel')
|
||||
->landingPage('BILLING')
|
||||
->build()
|
||||
)
|
||||
->build();
|
||||
|
||||
$collect = [
|
||||
'body' => $body,
|
||||
'prefer' => 'return=representation'
|
||||
];
|
||||
|
||||
try {
|
||||
$response = $ordersController->createOrder($collect);
|
||||
|
||||
if ($response->getStatusCode() === 201) {
|
||||
$result = $response->getResult();
|
||||
$approveLink = collect($result->links)->first(fn($link) => $link->rel === 'approve')?->href;
|
||||
|
||||
return response()->json([
|
||||
'id' => $result->id,
|
||||
'approve_url' => $approveLink,
|
||||
]);
|
||||
}
|
||||
|
||||
Log::error('PayPal order creation failed', ['response' => $response]);
|
||||
return response()->json(['error' => 'Order creation failed'], 400);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('PayPal order creation exception', ['error' => $e->getMessage()]);
|
||||
return response()->json(['error' => 'Order creation failed'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function captureOrder(Request $request)
|
||||
{
|
||||
$request->validate(['order_id' => 'required']);
|
||||
|
||||
$ordersController = $this->client->getOrdersController();
|
||||
|
||||
$collect = [
|
||||
'id' => $request->order_id,
|
||||
'prefer' => 'return=representation'
|
||||
];
|
||||
|
||||
try {
|
||||
$response = $ordersController->captureOrder($collect);
|
||||
|
||||
if ($response->getStatusCode() === 201) {
|
||||
$result = $response->getResult();
|
||||
$customId = $result->purchaseUnits[0]->customId ?? null;
|
||||
|
||||
if ($customId) {
|
||||
$metadata = json_decode($customId, true);
|
||||
$tenantId = $metadata['tenant_id'] ?? null;
|
||||
$packageId = $metadata['package_id'] ?? null;
|
||||
$type = $metadata['type'] ?? 'endcustomer_event';
|
||||
|
||||
if ($tenantId && $packageId) {
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$package = Package::findOrFail($packageId);
|
||||
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'provider_id' => $result->id,
|
||||
'price' => $result->purchaseUnits[0]->amount->value,
|
||||
'type' => $type,
|
||||
'purchased_at' => now(),
|
||||
'refunded' => false,
|
||||
]);
|
||||
TenantPackage::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$tenant->update(['subscription_status' => 'active']);
|
||||
} else {
|
||||
Log::error('Invalid metadata in PayPal custom_id', ['custom_id' => $customId]);
|
||||
}
|
||||
|
||||
Log::info('PayPal order captured and purchase created: ' . $result->id);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'captured', 'order' => $result]);
|
||||
}
|
||||
|
||||
Log::error('PayPal order capture failed', ['response' => $response]);
|
||||
return response()->json(['error' => 'Capture failed'], 400);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('PayPal order capture exception', ['error' => $e->getMessage()]);
|
||||
return response()->json(['error' => 'Capture failed'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function createSubscription(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'plan_id' => 'required|string',
|
||||
'tenant_id' => 'nullable|exists:tenants,id',
|
||||
]);
|
||||
|
||||
$tenant = $request->tenant_id
|
||||
? Tenant::findOrFail($request->tenant_id)
|
||||
: optional(Auth::user())->tenant;
|
||||
|
||||
if (! $tenant) {
|
||||
return response()->json(['error' => 'Tenant context required for subscription checkout.'], 422);
|
||||
}
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
|
||||
$ordersController = $this->client->getOrdersController();
|
||||
|
||||
$storedPaymentSource = new \PaypalServerSdkLib\Models\StoredPaymentSource(
|
||||
'CUSTOMER',
|
||||
'RECURRING'
|
||||
);
|
||||
$storedPaymentSource->setUsage('FIRST');
|
||||
|
||||
$paymentSource = new \PaypalServerSdkLib\Models\PaymentSource();
|
||||
$paymentSource->storedPaymentSource = $storedPaymentSource;
|
||||
|
||||
$body = OrderRequestBuilder::init(
|
||||
CheckoutPaymentIntent::CAPTURE,
|
||||
[
|
||||
PurchaseUnitRequestBuilder::init(
|
||||
AmountWithBreakdownBuilder::init('EUR', number_format($package->price, 2, '.', ''))
|
||||
->build()
|
||||
)
|
||||
->description('Subscription Package: ' . $package->name)
|
||||
->customId(json_encode([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'type' => 'reseller_subscription',
|
||||
'plan_id' => $request->plan_id
|
||||
]))
|
||||
->build()
|
||||
]
|
||||
)
|
||||
->paymentSource($paymentSource)
|
||||
->applicationContext(
|
||||
OrderApplicationContextBuilder::init()
|
||||
->brandName('Fotospiel')
|
||||
->landingPage('BILLING')
|
||||
->build()
|
||||
)
|
||||
->build();
|
||||
|
||||
$collect = [
|
||||
'body' => $body,
|
||||
'prefer' => 'return=representation'
|
||||
];
|
||||
|
||||
try {
|
||||
$response = $ordersController->createOrder($collect);
|
||||
|
||||
if ($response->getStatusCode() === 201) {
|
||||
$result = $response->getResult();
|
||||
$orderId = $result->id;
|
||||
|
||||
// Initial purchase record for subscription setup
|
||||
TenantPackage::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => now()->addYear(), // Assuming annual subscription
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'provider_id' => $orderId . '_sub_' . $request->plan_id, // Combine for uniqueness
|
||||
'price' => $package->price,
|
||||
'type' => 'reseller_subscription',
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
|
||||
$approveLink = collect($result->links)->first(fn($link) => $link->rel === 'approve')?->href;
|
||||
|
||||
return response()->json([
|
||||
'order_id' => $orderId,
|
||||
'approve_url' => $approveLink,
|
||||
]);
|
||||
}
|
||||
|
||||
Log::error('PayPal subscription order creation failed', ['response' => $response]);
|
||||
return response()->json(['error' => 'Subscription order creation failed'], 400);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('PayPal subscription order creation exception', ['error' => $e->getMessage()]);
|
||||
return response()->json(['error' => 'Subscription order creation failed'], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use PaypalServerSdkLib\Controllers\OrdersController;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Package;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use App\Services\PayPal\PaypalClientFactory;
|
||||
use App\Services\Checkout\CheckoutWebhookService;
|
||||
|
||||
class PayPalWebhookController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PaypalClientFactory $clientFactory,
|
||||
private CheckoutWebhookService $checkoutWebhooks,
|
||||
) {
|
||||
}
|
||||
|
||||
public function verify(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'webhook_id' => 'required|string',
|
||||
'webhook_event' => 'required|array',
|
||||
]);
|
||||
|
||||
$webhookId = $request->webhook_id;
|
||||
$event = $request->webhook_event;
|
||||
|
||||
$client = $this->clientFactory->make();
|
||||
|
||||
// Basic webhook validation - simplified for now
|
||||
// TODO: Implement proper webhook signature verification with official SDK
|
||||
$isValidWebhook = true; // Temporarily allow all webhooks for testing
|
||||
|
||||
try {
|
||||
if ($isValidWebhook) {
|
||||
// Process the webhook event
|
||||
$this->handleEvent($event);
|
||||
|
||||
return response()->json(['status' => 'SUCCESS'], 200);
|
||||
} else {
|
||||
Log::warning('PayPal webhook verification failed', ['status' => 'basic_validation_failed']);
|
||||
return response()->json(['status' => 'FAILURE'], 400);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('PayPal webhook verification error: ' . $e->getMessage());
|
||||
return response()->json(['status' => 'FAILURE'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleEvent(array $event): void
|
||||
{
|
||||
$eventType = $event['event_type'] ?? '';
|
||||
$resource = $event['resource'] ?? [];
|
||||
|
||||
Log::info('PayPal webhook received', ['event_type' => $eventType, 'resource_id' => $resource['id'] ?? 'unknown']);
|
||||
|
||||
if ($this->checkoutWebhooks->handlePayPalEvent($event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch ($eventType) {
|
||||
case 'CHECKOUT.ORDER.APPROVED':
|
||||
// Handle order approval if needed
|
||||
break;
|
||||
|
||||
case 'PAYMENT.CAPTURE.COMPLETED':
|
||||
$this->handleCaptureCompleted($resource);
|
||||
break;
|
||||
|
||||
case 'PAYMENT.CAPTURE.DENIED':
|
||||
$this->handleCaptureDenied($resource);
|
||||
break;
|
||||
|
||||
case 'BILLING.SUBSCRIPTION.ACTIVATED':
|
||||
// Handle subscription activation for SaaS
|
||||
$this->handleSubscriptionActivated($resource);
|
||||
break;
|
||||
|
||||
case 'BILLING.SUBSCRIPTION.CANCELLED':
|
||||
$this->handleSubscriptionCancelled($resource);
|
||||
break;
|
||||
|
||||
default:
|
||||
Log::info('Unhandled PayPal webhook event', ['event_type' => $eventType]);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleCaptureCompleted(array $capture): void
|
||||
{
|
||||
$orderId = $capture['order_id'] ?? null;
|
||||
if (!$orderId) {
|
||||
Log::warning('No order_id in PayPal capture webhook', ['capture_id' => $capture['id'] ?? 'unknown']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Idempotent check
|
||||
$purchase = PackagePurchase::where('provider_id', $orderId)->first();
|
||||
if ($purchase) {
|
||||
Log::info('PayPal order already processed', ['order_id' => $orderId]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch order to get custom_id
|
||||
$this->processPurchaseFromOrder($orderId, 'completed');
|
||||
}
|
||||
|
||||
private function handleCaptureDenied(array $capture): void
|
||||
{
|
||||
$orderId = $capture['id'] ?? null;
|
||||
Log::warning('PayPal capture denied', ['order_id' => $orderId]);
|
||||
|
||||
// Handle denial, e.g., notify tenant or refund logic if needed
|
||||
// For now, log
|
||||
}
|
||||
|
||||
private function handleSubscriptionActivated(array $subscription): void
|
||||
{
|
||||
$subscriptionId = $subscription['id'] ?? null;
|
||||
if (!$subscriptionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update tenant subscription status
|
||||
// Assume metadata has tenant_id
|
||||
$customId = $subscription['custom_id'] ?? null;
|
||||
if ($customId) {
|
||||
$metadata = json_decode($customId, true);
|
||||
$tenantId = $metadata['tenant_id'] ?? null;
|
||||
|
||||
if ($tenantId) {
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant) {
|
||||
$tenant->update(['subscription_status' => 'active']);
|
||||
Log::info('PayPal subscription activated', ['subscription_id' => $subscriptionId, 'tenant_id' => $tenantId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function handleSubscriptionCancelled(array $subscription): void
|
||||
{
|
||||
$subscriptionId = $subscription['id'] ?? null;
|
||||
if (!$subscriptionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update tenant to cancelled
|
||||
$customId = $subscription['custom_id'] ?? null;
|
||||
if ($customId) {
|
||||
$metadata = json_decode($customId, true);
|
||||
$tenantId = $metadata['tenant_id'] ?? null;
|
||||
|
||||
if ($tenantId) {
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant) {
|
||||
$tenant->update(['subscription_status' => 'expired']);
|
||||
// Deactivate TenantPackage
|
||||
TenantPackage::where('tenant_id', $tenantId)->update(['active' => false]);
|
||||
Log::info('PayPal subscription cancelled', ['subscription_id' => $subscriptionId, 'tenant_id' => $tenantId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function processPurchaseFromOrder(string $orderId, string $status): void
|
||||
{
|
||||
// Fetch order details
|
||||
$client = $this->clientFactory->make();
|
||||
|
||||
$ordersController = $client->getOrdersController();
|
||||
|
||||
try {
|
||||
$response = $ordersController->showOrder([
|
||||
'id' => $orderId,
|
||||
'prefer' => 'return=representation'
|
||||
]);
|
||||
$order = method_exists($response, 'getResult') ? $response->getResult() : ($response->result ?? null);
|
||||
|
||||
if (! $order) {
|
||||
Log::error('No order payload returned for PayPal order', ['order_id' => $orderId]);
|
||||
return;
|
||||
}
|
||||
|
||||
$customId = $order->purchaseUnits[0]->customId ?? null;
|
||||
if (!$customId) {
|
||||
Log::error('No custom_id in PayPal order', ['order_id' => $orderId]);
|
||||
return;
|
||||
}
|
||||
|
||||
$metadata = json_decode($customId, true);
|
||||
$tenantId = $metadata['tenant_id'] ?? null;
|
||||
$packageId = $metadata['package_id'] ?? null;
|
||||
|
||||
if (!$tenantId || !$packageId) {
|
||||
Log::error('Missing metadata in PayPal order', ['order_id' => $orderId, 'metadata' => $metadata]);
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
$package = Package::find($packageId);
|
||||
|
||||
if (!$tenant || !$package) {
|
||||
Log::error('Tenant or package not found for PayPal order', ['order_id' => $orderId]);
|
||||
return;
|
||||
}
|
||||
|
||||
$operation = function () use ($tenant, $package, $orderId, $status) {
|
||||
// Idempotent check
|
||||
$existing = PackagePurchase::where('provider_id', $orderId)->first();
|
||||
if ($existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'provider_id' => $orderId,
|
||||
'price' => $package->price,
|
||||
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
|
||||
'purchased_at' => now(),
|
||||
'status' => $status,
|
||||
'metadata' => json_encode(['paypal_order' => $orderId, 'webhook' => true]),
|
||||
]);
|
||||
|
||||
// For trial: if first purchase and reseller, set trial
|
||||
$activePackages = TenantPackage::where('tenant_id', $tenant->id)
|
||||
->where('active', true)
|
||||
->count();
|
||||
|
||||
$expiresAt = now()->addYear();
|
||||
if ($activePackages === 0 && $package->type === 'reseller_subscription') {
|
||||
$expiresAt = now()->addDays(14); // Trial
|
||||
}
|
||||
|
||||
TenantPackage::updateOrCreate(
|
||||
['tenant_id' => $tenant->id, 'package_id' => $package->id],
|
||||
[
|
||||
'price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'active' => true,
|
||||
'expires_at' => $expiresAt,
|
||||
]
|
||||
);
|
||||
|
||||
$tenant->update(['subscription_status' => 'active']);
|
||||
};
|
||||
|
||||
$connection = DB::connection();
|
||||
if ($connection->getDriverName() === 'sqlite' && $connection->transactionLevel() > 0) {
|
||||
$operation();
|
||||
} else {
|
||||
$connection->transaction($operation);
|
||||
}
|
||||
|
||||
Log::info('PayPal purchase processed via webhook', ['order_id' => $orderId, 'tenant_id' => $tenantId, 'status' => $status]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error processing PayPal order in webhook: ' . $e->getMessage(), ['order_id' => $orderId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ class ContentSecurityPolicy
|
||||
"'nonce-{$scriptNonce}'",
|
||||
'https://js.stripe.com',
|
||||
'https://js.stripe.network',
|
||||
'https://cdn.paddle.com',
|
||||
'https://global.localizecdn.com',
|
||||
];
|
||||
|
||||
$styleSources = [
|
||||
@@ -51,11 +53,22 @@ class ContentSecurityPolicy
|
||||
"'self'",
|
||||
'https://api.stripe.com',
|
||||
'https://api.stripe.network',
|
||||
'https://api.paddle.com',
|
||||
'https://sandbox-api.paddle.com',
|
||||
'https://checkout.paddle.com',
|
||||
'https://sandbox-checkout.paddle.com',
|
||||
'https://checkout-service.paddle.com',
|
||||
'https://sandbox-checkout-service.paddle.com',
|
||||
'https://global.localizecdn.com',
|
||||
];
|
||||
|
||||
$frameSources = [
|
||||
"'self'",
|
||||
'https://js.stripe.com',
|
||||
'https://checkout.paddle.com',
|
||||
'https://sandbox-checkout.paddle.com',
|
||||
'https://checkout-service.paddle.com',
|
||||
'https://sandbox-checkout-service.paddle.com',
|
||||
];
|
||||
|
||||
$imgSources = [
|
||||
|
||||
Reference in New Issue
Block a user