übergang auf pakete, integration von stripe und paypal, blog hinzugefügt.

This commit is contained in:
Codex Agent
2025-09-29 07:59:39 +02:00
parent 0a643c3e4d
commit e52a4005aa
83 changed files with 4284 additions and 629 deletions

View File

@@ -53,11 +53,7 @@ class EventController extends Controller
$tenant = Tenant::findOrFail($tenantId);
}
if (!$tenant->canCreateEvent()) {
return response()->json([
'error' => 'No available package for creating events. Please purchase a package.',
], 402);
}
// Package check is now handled by middleware
$validated = $request->validated();
$tenantId = $tenant->id;

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Package;
use App\Models\TenantPackage;
use App\Models\User;
use App\Models\Tenant;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Support\Str;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class MarketingRegisterController extends Controller
{
/**
* Show the registration form.
*/
public function create(Request $request): View
{
$package = null;
if ($request->has('package_id')) {
$package = Package::findOrFail($request->package_id);
}
return view('marketing.register', compact('package'));
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'username' => ['required', 'string', 'max:255', 'unique:' . User::class, 'alpha_dash'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:' . User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['required', 'string', 'max:255'],
'address' => ['required', 'string'],
'phone' => ['required', 'string', 'max:20'],
'privacy_consent' => ['required', 'accepted'],
'package_id' => ['nullable', 'exists:packages,id'],
]);
$user = User::create([
'name' => $request->name,
'username' => $request->username,
'email' => $request->email,
'password' => Hash::make($request->password),
'first_name' => $request->first_name,
'last_name' => $request->last_name,
'address' => $request->address,
'phone' => $request->phone,
'preferred_locale' => $request->preferred_locale ?? 'de',
]);
$tenant = Tenant::create([
'user_id' => $user->id,
'name' => $request->name,
'slug' => Str::slug($request->name . '-' . now()->timestamp),
'email' => $request->email,
]);
// If package_id provided and free, assign immediately
if ($request->package_id) {
$package = Package::find($request->package_id);
if ($package && $package->price == 0) {
TenantPackage::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'active' => true,
'expires_at' => now()->addYear(), // or based on package duration
]);
}
}
event(new Registered($user));
Auth::login($user);
return $user->hasVerifiedEmail()
? redirect()->intended('/admin')
: redirect()->route('verification.notice');
}
}

View File

@@ -12,6 +12,8 @@ use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Inertia\Inertia;
use Inertia\Response;
use App\Models\Tenant;
use Illuminate\Support\Str;
class RegisteredUserController extends Controller
{
@@ -42,6 +44,13 @@ class RegisteredUserController extends Controller
'password' => Hash::make($request->password),
]);
$tenant = Tenant::create([
'user_id' => $user->id,
'name' => $request->name,
'slug' => Str::slug($request->name . '-' . now()->timestamp),
'email' => $request->email,
]);
event(new Registered($user));
Auth::login($user);

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Stripe\Stripe;
use Stripe\Checkout\Session;
@@ -18,12 +19,16 @@ use PayPal\Rest\ApiContext;
use PayPal\Auth\OAuthTokenCredential;
use App\Models\Tenant;
use App\Models\EventPurchase;
use App\Models\Package;
use App\Models\TenantPackage;
use App\Models\PackagePurchase;
use Illuminate\Support\Facades\Auth;
class MarketingController extends Controller
{
public function __construct()
{
\Stripe\Stripe::setApiKey(config('services.stripe.key'));
Stripe::setApiKey(config('services.stripe.key'));
}
public function index()
@@ -53,74 +58,155 @@ class MarketingController extends Controller
return redirect()->back()->with('success', 'Nachricht gesendet!');
}
public function checkout(Request $request, $package)
/**
* Handle package purchase flow.
*/
public function buyPackages(Request $request, $packageId)
{
$packages = [
'basic' => ['name' => 'Basic', 'price' => 0, 'events' => 1],
'standard' => ['name' => 'Standard', 'price' => 9900, 'events' => 10], // cents
'premium' => ['name' => 'Premium', 'price' => 19900, 'events' => 50],
];
$package = Package::findOrFail($packageId);
if (!isset($packages[$package])) {
abort(404);
if (!Auth::check()) {
return redirect()->route('register', ['package_id' => $package->id])
->with('message', __('marketing.packages.register_required'));
}
$pkg = $packages[$package];
$user = Auth::user();
if (!$user->email_verified_at) {
return redirect()->route('verification.notice')
->with('message', __('auth.verification_required'));
}
if ($pkg['price'] == 0) {
// Free package: create tenant and event
$tenant = Tenant::create([
'name' => $request->input('tenant_name', 'New Tenant'),
'slug' => Str::slug('new-' . now()),
'email' => $request->input('email'),
'events_remaining' => $pkg['events'],
]);
$tenant = $user->tenant;
if (!$tenant) {
abort(500, 'Tenant not found');
}
// Create initial event
$event = $tenant->events()->create([
'name' => $request->input('event_name', 'My Event'),
'slug' => Str::slug($request->input('event_name', 'my-event')),
'status' => 'active',
]);
if ($package->price == 0) {
TenantPackage::updateOrCreate(
[
'tenant_id' => $tenant->id,
'package_id' => $package->id,
],
[
'active' => true,
'purchased_at' => now(),
'expires_at' => now()->addYear(),
]
);
$purchase = EventPurchase::create([
PackagePurchase::create([
'tenant_id' => $tenant->id,
'events_purchased' => $pkg['events'],
'amount' => 0,
'currency' => 'EUR',
'provider' => 'free',
'status' => 'completed',
'package_id' => $package->id,
'provider_id' => 'free',
'price' => 0,
'type' => $package->type,
'purchased_at' => now(),
'refunded' => false,
]);
return redirect("/admin/tenants/{$tenant->id}/edit")->with('success', 'Konto erstellt! Willkommen bei Fotospiel.');
return redirect('/admin')->with('success', __('marketing.packages.free_assigned'));
}
$stripe = new \Stripe\StripeClient(config('services.stripe.secret'));
if ($request->input('provider') === 'paypal') {
return $this->paypalCheckout($request, $packageId);
}
return $this->checkout($request, $packageId);
}
/**
* 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' => $pkg['name'] . ' Package',
'name' => $package->name,
],
'unit_amount' => $pkg['price'],
'unit_amount' => $package->price * 100,
],
'quantity' => 1,
]],
'mode' => 'payment',
'success_url' => route('marketing.success', $package),
'cancel_url' => route('marketing'),
'success_url' => route('marketing.success', $packageId),
'cancel_url' => route('packages'),
'metadata' => [
'package' => $package,
'events' => $pkg['events'],
'user_id' => $user->id,
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => $package->type,
],
]);
return redirect($session->url, 303);
}
/**
* PayPal checkout with auth metadata.
*/
public function paypalCheckout(Request $request, $packageId)
{
$package = Package::findOrFail($packageId);
$user = Auth::user();
$tenant = $user->tenant;
$apiContext = new ApiContext(
new OAuthTokenCredential(
config('services.paypal.client_id'),
config('services.paypal.secret')
)
);
$payment = new Payment();
$payer = new Payer();
$payer->setPaymentMethod('paypal');
$amountObj = new Amount();
$amountObj->setCurrency('EUR');
$amountObj->setTotal($package->price);
$transaction = new Transaction();
$transaction->setAmount($amountObj);
$redirectUrls = new RedirectUrls();
$redirectUrls->setReturnUrl(route('marketing.success', $packageId));
$redirectUrls->setCancelUrl(route('packages'));
$customData = json_encode([
'user_id' => $user->id,
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => $package->type,
]);
$payment->setIntent('sale')
->setPayer($payer)
->setTransactions([$transaction])
->setRedirectUrls($redirectUrls)
->setNoteToPayer('Package: ' . $package->name)
->setCustom($customData);
try {
$payment->create($apiContext);
session(['paypal_payment_id' => $payment->getId()]);
return redirect($payment->getApprovalLink());
} catch (\Exception $e) {
Log::error('PayPal checkout error: ' . $e->getMessage());
return back()->with('error', 'Zahlung fehlgeschlagen');
}
}
public function stripeCheckout($sessionId)
{
// Handle Stripe success
@@ -154,82 +240,4 @@ class MarketingController extends Controller
return view('marketing.blog-show', compact('post'));
}
public function paypalCheckout(Request $request, $package)
{
$packages = [
'basic' => ['name' => 'Basic', 'price' => 0, 'events' => 1],
'standard' => ['name' => 'Standard', 'price' => 99, 'events' => 10],
'premium' => ['name' => 'Premium', 'price' => 199, 'events' => 50],
];
if (!isset($packages[$package])) {
abort(404);
}
$pkg = $packages[$package];
if ($pkg['price'] == 0) {
// Free package: create tenant and event
$tenant = Tenant::create([
'name' => $request->input('tenant_name', 'New Tenant'),
'slug' => Str::slug('new-' . now()),
'email' => $request->input('email'),
'events_remaining' => $pkg['events'],
]);
// Create initial event
$event = $tenant->events()->create([
'name' => $request->input('event_name', 'My Event'),
'slug' => Str::slug($request->input('event_name', 'my-event')),
'status' => 'active',
]);
$purchase = EventPurchase::create([
'tenant_id' => $tenant->id,
'events_purchased' => $pkg['events'],
'amount' => 0,
'currency' => 'EUR',
'provider' => 'free',
'status' => 'completed',
'purchased_at' => now(),
]);
return redirect("/admin/tenants/{$tenant->id}/edit")->with('success', 'Konto erstellt! Willkommen bei Fotospiel.');
}
$apiContext = new ApiContext(
new OAuthTokenCredential(
config('services.paypal.client_id'),
config('services.paypal.secret')
)
);
$payment = new Payment();
$payer = new Payer();
$payer->setPaymentMethod('paypal');
$amountObj = new Amount();
$amountObj->setCurrency('EUR');
$amountObj->setTotal($pkg['price']);
$transaction = new Transaction();
$transaction->setAmount($amountObj);
$redirectUrls = new RedirectUrls();
$redirectUrls->setReturnUrl(route('marketing.success', $package));
$redirectUrls->setCancelUrl(route('marketing'));
$payment->setIntent('sale')
->setPayer($payer)
->setTransactions([$transaction])
->setRedirectUrls($redirectUrls);
try {
$payment->create($apiContext);
return redirect($payment->getApprovalLink());
} catch (Exception $e) {
return back()->with('error', 'Zahlung fehlgeschlagen');
}
}
}

View File

@@ -0,0 +1,235 @@
<?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\PaypalServerSdkClientBuilder;
use PaypalServerSdkLib\Auth\ClientCredentialsAuthCredentialsBuilder;
use PaypalServerSdkLib\Environment;
use PaypalServerSdkLib\Logging\LoggingConfigurationBuilder;
use PaypalServerSdkLib\Logging\RequestLoggingConfigurationBuilder;
use PaypalServerSdkLib\Logging\ResponseLoggingConfigurationBuilder;
use PaypalServerSdkLib\Logging\LogLevel;
use PaypalServerSdkLib\Orders\OrderRequestBuilder;
use PaypalServerSdkLib\Orders\CheckoutPaymentIntent;
use PaypalServerSdkLib\Orders\PurchaseUnitRequestBuilder;
use PaypalServerSdkLib\Orders\AmountWithBreakdownBuilder;
use PaypalServerSdkLib\Orders\ApplicationContextBuilder;
use PaypalServerSdkLib\Subscriptions\SubscriptionRequestBuilder;
use PaypalServerSdkLib\Subscriptions\SubscriberBuilder;
use PaypalServerSdkLib\Subscriptions\NameBuilder;
use PaypalServerSdkLib\Subscriptions\ApplicationContextSubscriptionBuilder;
use PaypalServerSdkLib\Subscriptions\ShippingPreference;
class PayPalController extends Controller
{
private $client;
public function __construct()
{
$clientId = config('services.paypal.client_id');
$clientSecret = config('services.paypal.secret');
$this->client = PaypalServerSdkClientBuilder::init()
->clientCredentialsAuthCredentials(
ClientCredentialsAuthCredentialsBuilder::init($clientId, $clientSecret)
)
->environment(config('app.env') === 'production' ? Environment::PRODUCTION : Environment::SANDBOX)
->loggingConfiguration(
LoggingConfigurationBuilder::init()
->level(LogLevel::INFO)
->requestConfiguration(RequestLoggingConfigurationBuilder::init()->body(true))
->responseConfiguration(ResponseLoggingConfigurationBuilder::init()->headers(true))
)
->build();
}
public function createOrder(Request $request)
{
$request->validate([
'tenant_id' => 'required|exists:tenants,id',
'package_id' => 'required|exists:packages,id',
]);
$tenant = Tenant::findOrFail($request->tenant_id);
$package = Package::findOrFail($request->package_id);
$ordersController = $this->client->getOrdersController();
$requestBody = OrderRequestBuilder::init(CheckoutPaymentIntent::CAPTURE)
->purchaseUnits([
PurchaseUnitRequestBuilder::init()
->amount(
AmountWithBreakdownBuilder::init('EUR', number_format($package->price, 2, '.', ''))
->build()
)
->description('Package: ' . $package->name)
->customId($tenant->id . '_' . $package->id . '_endcustomer_event')
->build()
])
->applicationContext(
ApplicationContextBuilder::init()
->shippingPreference(ShippingPreference::NO_SHIPPING)
->userAction('PAY_NOW')
->build()
)
->build();
$collect = [
'body' => $requestBody,
'prefer' => 'return=representation'
];
try {
$response = $ordersController->createOrder($collect);
if ($response->statusCode === 201) {
$result = $response->result;
$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->statusCode === 201) {
$result = $response->result;
$customId = $result->purchaseUnits[0]->customId ?? null;
if ($customId) {
[$tenantId, $packageId, $type] = explode('_', $customId);
$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 ?? 'endcustomer_event',
'purchased_at' => now(),
'refunded' => false,
]);
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([
'tenant_id' => 'required|exists:tenants,id',
'package_id' => 'required|exists:packages,id',
'plan_id' => 'required', // PayPal plan ID for the package
]);
$tenant = Tenant::findOrFail($request->tenant_id);
$package = Package::findOrFail($request->package_id);
$subscriptionsController = $this->client->getSubscriptionsController();
$requestBody = SubscriptionRequestBuilder::init()
->planId($request->plan_id)
->subscriber(
SubscriberBuilder::init()
->name(
NameBuilder::init()
->givenName($tenant->name ?? 'Tenant')
->build()
)
->emailAddress($tenant->email)
->build()
)
->customId($tenant->id . '_' . $package->id . '_reseller_subscription')
->applicationContext(
ApplicationContextSubscriptionBuilder::init()
->shippingPreference(ShippingPreference::NO_SHIPPING)
->userAction('SUBSCRIBE_NOW')
->build()
)
->build();
$collect = [
'body' => $requestBody,
'prefer' => 'return=representation'
];
try {
$response = $subscriptionsController->createSubscription($collect);
if ($response->statusCode === 201) {
$result = $response->result;
$subscriptionId = $result->id;
TenantPackage::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'purchased_at' => now(),
'expires_at' => now()->addYear(),
'active' => true,
]);
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider_id' => $subscriptionId,
'price' => $package->price,
'type' => 'reseller_subscription',
'purchased_at' => now(),
]);
$approveLink = collect($result->links)->first(fn($link) => $link->rel === 'approve')?->href;
return response()->json([
'subscription_id' => $subscriptionId,
'approve_url' => $approveLink,
]);
}
Log::error('PayPal subscription creation failed', ['response' => $response]);
return response()->json(['error' => 'Subscription creation failed'], 400);
} catch (\Exception $e) {
Log::error('PayPal subscription creation exception', ['error' => $e->getMessage()]);
return response()->json(['error' => 'Subscription creation failed'], 500);
}
}
}

View File

@@ -16,32 +16,60 @@ class PayPalWebhookController extends Controller
$payerEmail = $input['payer_email'] ?? null;
$paymentStatus = $input['payment_status'] ?? null;
$mcGross = $input['mc_gross'] ?? 0;
$packageId = $input['custom'] ?? null;
$custom = $input['custom'] ?? null;
if ($paymentStatus === 'Completed' && $mcGross > 0) {
// Verify IPN with PayPal (simplified; use SDK for full verification)
// $verified = $this->verifyIPN($input);
// Find or create tenant (for public checkout, perhaps create new or use session)
// For now, assume tenant_id from custom or session
$tenantId = $packageId ? Tenant::where('slug', $packageId)->first()->id ?? 1 : 1;
// Parse custom for user_id or tenant_id
$data = json_decode($custom, true);
$userId = $data['user_id'] ?? null;
$tenantId = $data['tenant_id'] ?? null;
$packageId = $data['package_id'] ?? null;
// Create purchase and increment credits
$purchase = EventPurchase::create([
'tenant_id' => $tenantId, // Implement tenant resolution
'events_purchased' => $mcGross / 49, // Example: 49€ per event credit
'amount' => $mcGross,
'currency' => $input['mc_currency'] ?? 'EUR',
'provider' => 'paypal',
'external_receipt_id' => $ipnMessage,
'status' => 'completed',
if ($userId && !$tenantId) {
$tenant = \App\Models\Tenant::where('user_id', $userId)->first();
if ($tenant) {
$tenantId = $tenant->id;
} else {
Log::error('Tenant not found for user_id in PayPal IPN: ' . $userId);
return response('OK', 200);
}
}
if (!$tenantId || !$packageId) {
Log::error('Missing tenant or package in PayPal IPN custom data');
return response('OK', 200);
}
// Create PackagePurchase
\App\Models\PackagePurchase::create([
'tenant_id' => $tenantId,
'package_id' => $packageId,
'provider_id' => $ipnMessage,
'price' => $mcGross,
'type' => $data['type'] ?? 'reseller_subscription',
'purchased_at' => now(),
'refunded' => false,
]);
$tenant = Tenant::find($tenantId);
$tenant->incrementCredits($purchase->events_purchased, 'paypal_purchase', 'PayPal IPN', $purchase->id);
// Update TenantPackage if subscription
if ($data['type'] ?? '' === 'reseller_subscription') {
\App\Models\TenantPackage::updateOrCreate(
[
'tenant_id' => $tenantId,
'package_id' => $packageId,
],
[
'active' => true,
'purchased_at' => now(),
'expires_at' => now()->addYear(),
]
);
}
Log::info('PayPal IPN processed', $input);
Log::info('PayPal IPN processed for tenant ' . $tenantId . ', package ' . $packageId, $input);
}
return response('OK', 200);

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
use Illuminate\Http\RedirectResponse;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
{
return view('profile.edit', [
'user' => $request->user(),
]);
}
/**
* Update the user's profile information.
*/
public function update(Request $request, User $user): RedirectResponse
{
// Authorized via auth middleware
$request->validate([
'name' => 'required|string|max:255',
'username' => ['required', 'string', 'max:255', 'alpha_dash', 'unique:users,username,' . $user->id],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email,' . $user->id],
'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['required', 'string', 'max:255'],
'address' => ['required', 'string'],
'phone' => ['required', 'string', 'max:20'],
]);
$user->update($request->only([
'name', 'username', 'email', 'first_name', 'last_name', 'address', 'phone'
]));
return back()->with('status', 'profile-updated');
}
/**
* Update the user's password.
*/
public function updatePassword(Request $request, User $user): RedirectResponse
{
// Authorized via auth middleware
$request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user->update([
'password' => Hash::make($request->password),
]);
return back()->with('status', 'password-updated');
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Http\Controllers;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Stripe\Stripe;
use Stripe\PaymentIntent;
use Stripe\Subscription;
class StripeController extends Controller
{
public function __construct()
{
Stripe::setApiKey(config('services.stripe.secret'));
}
public function createPaymentIntent(Request $request)
{
$request->validate([
'tenant_id' => 'required|exists:tenants,id',
'package_id' => 'required|exists:packages,id',
'amount' => 'required|numeric|min:0',
]);
$tenant = Tenant::findOrFail($request->tenant_id);
$package = \App\Models\Package::findOrFail($request->package_id);
$paymentIntent = PaymentIntent::create([
'amount' => $request->amount * 100, // cents
'currency' => 'eur',
'metadata' => [
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => 'endcustomer_event', // or reseller
],
]);
return response()->json([
'client_secret' => $paymentIntent->client_secret,
]);
}
public function createSubscription(Request $request)
{
$request->validate([
'tenant_id' => 'required|exists:tenants,id',
'package_id' => 'required|exists:packages,id',
'payment_method_id' => 'required',
]);
$tenant = Tenant::findOrFail($request->tenant_id);
$package = \App\Models\Package::findOrFail($request->package_id);
$subscription = Subscription::create([
'customer' => $tenant->stripe_customer_id ?? $this->createCustomer($tenant),
'items' => [[
'price' => $package->stripe_price_id, // Assume package has stripe_price_id
]],
'payment_method' => $request->payment_method_id,
'default_payment_method' => $request->payment_method_id,
'expand' => ['latest_invoice.payment_intent'],
'metadata' => [
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => 'reseller_subscription',
],
]);
// Create TenantPackage and PackagePurchase
\App\Models\TenantPackage::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'purchased_at' => now(),
'expires_at' => now()->addYear(),
'active' => true,
]);
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider_id' => $subscription->id,
'price' => $package->price,
'type' => 'reseller_subscription',
'purchased_at' => now(),
]);
return response()->json([
'subscription_id' => $subscription->id,
'client_secret' => $subscription->latest_invoice->payment_intent->client_secret ?? null,
]);
}
private function createCustomer(Tenant $tenant)
{
$customer = \Stripe\Customer::create([
'email' => $tenant->email,
'metadata' => ['tenant_id' => $tenant->id],
]);
$tenant->update(['stripe_customer_id' => $customer->id]);
return $customer->id;
}
}

View File

@@ -2,90 +2,169 @@
namespace App\Http\Controllers;
use App\Models\EventPurchase;
use App\Models\Tenant;
use App\Models\PackagePurchase;
use App\Models\TenantPackage;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Stripe\Stripe;
use Stripe\Webhook;
class StripeWebhookController extends Controller
{
public function handle(Request $request)
public function __construct()
{
Stripe::setApiKey(config('services.stripe.secret'));
}
public function handleWebhook(Request $request)
{
$payload = $request->getContent();
$sig = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook');
$sigHeader = $request->header('Stripe-Signature');
$endpointSecret = config('services.stripe.webhook_secret');
if (!$secret || !$sig) {
abort(400, 'Missing signature');
try {
$event = Webhook::constructEvent($payload, $sigHeader, $endpointSecret);
} catch (\Exception $e) {
Log::error('Stripe webhook signature verification failed: ' . $e->getMessage());
return response()->json(['error' => 'Invalid signature'], 400);
}
$expectedSig = 'v1=' . hash_hmac('sha256', $payload, $secret);
switch ($event['type']) {
case 'payment_intent.succeeded':
$paymentIntent = $event['data']['object'];
$this->handlePaymentIntentSucceeded($paymentIntent);
break;
if (!hash_equals($expectedSig, $sig)) {
abort(400, 'Invalid signature');
case 'invoice.payment_succeeded':
$invoice = $event['data']['object'];
$this->handleInvoicePaymentSucceeded($invoice);
break;
case 'invoice.payment_failed':
$invoice = $event['data']['object'];
$this->handleInvoicePaymentFailed($invoice);
break;
default:
Log::info('Unhandled Stripe event type: ' . $event['type']);
}
$event = json_decode($payload, true);
return response()->json(['status' => 'success']);
}
if (json_last_error() !== JSON_ERROR_NONE) {
Log::error('Invalid JSON in Stripe webhook: ' . json_last_error_msg());
return response('', 200);
private function handlePaymentIntentSucceeded($paymentIntent)
{
$metadata = $paymentIntent['metadata'];
if (!$metadata['user_id'] && !$metadata['tenant_id'] || !$metadata['package_id']) {
Log::warning('Missing metadata in Stripe payment intent: ' . $paymentIntent['id']);
return;
}
if ($event['type'] === 'checkout.session.completed') {
$session = $event['data']['object'];
$receiptId = $session['id'];
$userId = $metadata['user_id'] ?? null;
$tenantId = $metadata['tenant_id'] ?? null;
$packageId = $metadata['package_id'];
$type = $metadata['type'] ?? 'endcustomer_event';
// Idempotency check
if (EventPurchase::where('external_receipt_id', $receiptId)->exists()) {
return response('', 200);
if ($userId && !$tenantId) {
$tenant = \App\Models\Tenant::where('user_id', $userId)->first();
if ($tenant) {
$tenantId = $tenant->id;
} else {
Log::error('Tenant not found for user_id: ' . $userId);
return;
}
$tenantId = $session['metadata']['tenant_id'] ?? null;
if (!$tenantId) {
Log::warning('No tenant_id in Stripe metadata', ['receipt_id' => $receiptId]);
// Dispatch job for retry or manual resolution
\App\Jobs\ValidateStripeWebhookJob::dispatch($payload, $sig);
return response('', 200);
}
$tenant = Tenant::find($tenantId);
if (!$tenant) {
Log::error('Tenant not found for Stripe webhook', ['tenant_id' => $tenantId]);
\App\Jobs\ValidateStripeWebhookJob::dispatch($payload, $sig);
return response('', 200);
}
$amount = $session['amount_total'] / 100;
$currency = $session['currency'];
$eventsPurchased = (int) ($session['metadata']['events_purchased'] ?? 1);
DB::transaction(function () use ($tenant, $amount, $currency, $eventsPurchased, $receiptId) {
$purchase = EventPurchase::create([
'tenant_id' => $tenant->id,
'events_purchased' => $eventsPurchased,
'amount' => $amount,
'currency' => $currency,
'provider' => 'stripe',
'external_receipt_id' => $receiptId,
'status' => 'completed',
'purchased_at' => now(),
]);
$tenant->incrementCredits($eventsPurchased, 'purchase', null, $purchase->id);
});
Log::info('Processed Stripe purchase', ['receipt_id' => $receiptId, 'tenant_id' => $tenantId]);
} else {
// For other event types, log or dispatch job if needed
Log::info('Unhandled Stripe event', ['type' => $event['type']]);
// Optionally dispatch job for processing other events
\App\Jobs\ValidateStripeWebhookJob::dispatch($payload, $sig);
}
return response('', 200);
if (!$tenantId) {
Log::error('No tenant_id found for Stripe payment intent: ' . $paymentIntent['id']);
return;
}
// Create PackagePurchase for one-off payment
\App\Models\PackagePurchase::create([
'tenant_id' => $tenantId,
'package_id' => $packageId,
'provider_id' => $paymentIntent['id'],
'price' => $paymentIntent['amount_received'] / 100,
'type' => $type,
'purchased_at' => now(),
'refunded' => false,
]);
if ($type === 'endcustomer_event') {
// For event packages, assume event_id from metadata or handle separately
// TODO: Link to specific event if provided
}
Log::info('Package purchase created via Stripe payment intent: ' . $paymentIntent['id'] . ' for tenant ' . $tenantId);
}
private function handleInvoicePaymentSucceeded($invoice)
{
$subscription = $invoice['subscription'];
$metadata = $invoice['metadata'];
if (!$metadata['user_id'] && !$metadata['tenant_id'] || !$metadata['package_id']) {
Log::warning('Missing metadata in Stripe invoice: ' . $invoice['id']);
return;
}
$userId = $metadata['user_id'] ?? null;
$tenantId = $metadata['tenant_id'] ?? null;
$packageId = $metadata['package_id'];
if ($userId && !$tenantId) {
$tenant = \App\Models\Tenant::where('user_id', $userId)->first();
if ($tenant) {
$tenantId = $tenant->id;
} else {
Log::error('Tenant not found for user_id: ' . $userId);
return;
}
}
if (!$tenantId) {
Log::error('No tenant_id found for Stripe invoice: ' . $invoice['id']);
return;
}
// Update or create TenantPackage for subscription
\App\Models\TenantPackage::updateOrCreate(
[
'tenant_id' => $tenantId,
'package_id' => $packageId,
],
[
'purchased_at' => now(),
'expires_at' => now()->addYear(), // Renew annually
'active' => true,
]
);
// Create or update PackagePurchase
\App\Models\PackagePurchase::updateOrCreate(
[
'tenant_id' => $tenantId,
'package_id' => $packageId,
'provider_id' => $subscription,
],
[
'price' => $invoice['amount_paid'] / 100,
'type' => 'reseller_subscription',
'purchased_at' => now(),
'refunded' => false,
]
);
Log::info('Subscription renewed via Stripe invoice: ' . $invoice['id'] . ' for tenant ' . $tenantId);
}
private function handleInvoicePaymentFailed($invoice)
{
$subscription = $invoice['subscription'];
Log::warning('Stripe invoice payment failed: ' . $invoice['id'] . ' for subscription ' . $subscription);
// TODO: Deactivate package or notify tenant
// e.g., TenantPackage::where('provider_id', $subscription)->update(['active' => false]);
}
}