Checkout‑Registrierung validiert jetzt die E‑Mail‑Länge, und die Checkout‑Flows sind Paddle‑only: Stripe‑Endpoints/

Services/Helpers sind entfernt, API/Frontend angepasst, Tests auf Paddle umgestellt. Außerdem wurde die CSP gestrafft
  und Stripe‑Texte in den Abandoned‑Checkout‑Mails ersetzt.
This commit is contained in:
Codex Agent
2025-12-18 11:14:42 +01:00
parent 7213aef108
commit 2e4226a838
33 changed files with 314 additions and 1219 deletions

View File

@@ -47,8 +47,10 @@ class PackageController extends Controller
$request->validate([
'package_id' => 'required|exists:packages,id',
'type' => 'required|in:endcustomer,reseller',
'payment_method' => 'required|in:stripe,paddle',
'payment_method' => 'required|in:paddle',
'event_id' => 'nullable|exists:events,id', // For endcustomer
'success_url' => 'nullable|url',
'return_url' => 'nullable|url',
]);
$package = Package::findOrFail($request->package_id);
@@ -67,42 +69,11 @@ class PackageController extends Controller
return $this->handlePaidPurchase($request, $package, $tenant);
}
public function createPaymentIntent(Request $request): JsonResponse
{
$request->validate([
'package_id' => 'required|exists:packages,id',
]);
$package = Package::findOrFail($request->package_id);
$tenant = $request->attributes->get('tenant');
if (! $tenant) {
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
}
\Stripe\Stripe::setApiKey(config('services.stripe.secret'));
$paymentIntent = \Stripe\PaymentIntent::create([
'amount' => $package->price * 100,
'currency' => 'eur',
'metadata' => [
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => 'endcustomer_event',
],
]);
return response()->json([
'client_secret' => $paymentIntent->client_secret,
]);
}
public function completePurchase(Request $request): JsonResponse
{
$request->validate([
'package_id' => 'required|exists:packages,id',
'payment_method_id' => 'required_without:paddle_transaction_id|string',
'paddle_transaction_id' => 'required_without:payment_method_id|string',
'paddle_transaction_id' => 'required|string',
]);
$package = Package::findOrFail($request->package_id);
@@ -112,14 +83,14 @@ class PackageController extends Controller
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
}
$provider = $request->has('paddle_transaction_id') ? 'paddle' : 'stripe';
$provider = 'paddle';
DB::transaction(function () use ($request, $package, $tenant, $provider) {
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider' => $provider,
'provider_id' => $request->input($provider === 'paddle' ? 'paddle_transaction_id' : 'payment_method_id'),
'provider_id' => $request->input('paddle_transaction_id'),
'price' => $package->price,
'type' => 'endcustomer_event',
'purchased_at' => now(),
@@ -261,16 +232,19 @@ class PackageController extends Controller
private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse
{
$type = $request->type;
if ($type === 'reseller_subscription') {
$response = (new StripeController)->createSubscription($request);
return $response;
} else {
$response = (new StripeController)->createPaymentIntent($request);
return $response;
if (! $package->paddle_price_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
}
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
'success_url' => $request->input('success_url'),
'return_url' => $request->input('return_url'),
'metadata' => array_filter([
'type' => $request->input('type'),
'event_id' => $request->input('event_id'),
]),
]);
return response()->json($checkout);
}
}

View File

@@ -1,100 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\PackagePurchase;
use App\Models\EventPackage;
use App\Models\TenantPackage;
use Illuminate\Http\Request;
use Stripe\Stripe;
use Stripe\PaymentIntent;
use Stripe\Subscription;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
class StripeController extends Controller
{
public function __construct()
{
Stripe::setApiKey(config('services.stripe.secret'));
}
public function createPaymentIntent(Request $request)
{
$request->validate([
'package_id' => 'required|exists:packages,id',
'type' => 'required|in:endcustomer_event,reseller_subscription',
'tenant_id' => 'nullable|exists:tenants,id', // For reseller
'event_id' => 'nullable|exists:events,id', // For endcustomer
]);
$package = \App\Models\Package::findOrFail($request->package_id);
$amount = $package->price * 100; // Cents
$metadata = [
'package_id' => $package->id,
'type' => $request->type,
];
if ($request->tenant_id) {
$metadata['tenant_id'] = $request->tenant_id;
}
if ($request->event_id) {
$metadata['event_id'] = $request->event_id;
}
$intent = PaymentIntent::create([
'amount' => $amount,
'currency' => 'eur',
'metadata' => $metadata,
]);
return response()->json([
'client_secret' => $intent->client_secret,
]);
}
public function createSubscription(Request $request)
{
$request->validate([
'package_id' => 'required|exists:packages,id',
'tenant_id' => 'required|exists:tenants,id',
]);
$package = \App\Models\Package::findOrFail($request->package_id);
$tenant = \App\Models\Tenant::findOrFail($request->tenant_id);
// Assume customer exists or create
$customer = $tenant->stripe_customer_id ? \Stripe\Customer::retrieve($tenant->stripe_customer_id) : \Stripe\Customer::create([
'email' => $tenant->email,
'metadata' => ['tenant_id' => $tenant->id],
]);
$subscription = Subscription::create([
'customer' => $customer->id,
'items' => [[
'price' => $package->stripe_price_id, // Assume price ID set in package
]],
'metadata' => [
'tenant_id' => $tenant->id,
'package_id' => $package->id,
],
]);
// Create initial tenant package
TenantPackage::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'stripe_subscription_id' => $subscription->id,
'active' => true,
'expires_at' => now()->addYear(),
]);
return response()->json([
'subscription_id' => $subscription->id,
]);
}
}

View File

@@ -1,189 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\EventPackage;
use App\Models\PackagePurchase;
use App\Models\TenantPackage;
use App\Models\User;
use App\Services\Checkout\CheckoutWebhookService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
class StripeWebhookController extends Controller
{
public function __construct(private CheckoutWebhookService $checkoutWebhooks) {}
public function handleWebhook(Request $request)
{
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
$endpointSecret = config('services.stripe.webhook_secret');
try {
$event = Webhook::constructEvent(
$payload,
$sigHeader,
$endpointSecret
);
} catch (SignatureVerificationException $e) {
return ApiError::response(
'stripe_invalid_signature',
'Ungültige Signatur',
'Die Signatur der Stripe-Anfrage ist ungültig.',
400
);
} catch (\UnexpectedValueException $e) {
return ApiError::response(
'stripe_invalid_payload',
'Ungültige Daten',
'Der Stripe Payload konnte nicht gelesen werden.',
400
);
}
$eventArray = method_exists($event, 'toArray') ? $event->toArray() : (array) $event;
if ($this->checkoutWebhooks->handleStripeEvent($eventArray)) {
return response()->json(['status' => 'success'], 200);
}
// Legacy handlers for legacy marketing checkout
return $this->handleLegacyEvent($eventArray);
}
private function handleLegacyEvent(array $event)
{
$type = $event['type'] ?? null;
switch ($type) {
case 'payment_intent.succeeded':
$paymentIntent = $event['data']['object'] ?? [];
$this->handlePaymentIntentSucceeded($paymentIntent);
break;
case 'invoice.paid':
$invoice = $event['data']['object'] ?? [];
$this->handleInvoicePaid($invoice);
break;
default:
Log::info('Unhandled Stripe event', ['type' => $type]);
}
return response()->json(['status' => 'success'], 200);
}
private function handlePaymentIntentSucceeded(array $paymentIntent): void
{
$metadata = $paymentIntent['metadata'] ?? [];
$packageId = $metadata['package_id'] ?? null;
$type = $metadata['type'] ?? null;
if (! $packageId || ! $type) {
Log::warning('Stripe intent missing metadata payload', ['metadata' => $metadata]);
return;
}
DB::transaction(function () use ($paymentIntent, $metadata, $packageId, $type) {
$purchase = PackagePurchase::create([
'package_id' => $packageId,
'type' => $type,
'provider_id' => 'stripe',
'transaction_id' => $paymentIntent['id'] ?? null,
'price' => isset($paymentIntent['amount_received'])
? $paymentIntent['amount_received'] / 100
: 0,
'metadata' => $metadata,
]);
if ($type === 'endcustomer_event') {
$eventId = $metadata['event_id'] ?? null;
if (! $eventId) {
return;
}
EventPackage::create([
'event_id' => $eventId,
'package_id' => $packageId,
'package_purchase_id' => $purchase->id,
'used_photos' => 0,
'used_guests' => 0,
'expires_at' => now()->addDays(30),
]);
} elseif ($type === 'reseller_subscription') {
$tenantId = $metadata['tenant_id'] ?? null;
if (! $tenantId) {
return;
}
TenantPackage::create([
'tenant_id' => $tenantId,
'package_id' => $packageId,
'package_purchase_id' => $purchase->id,
'used_events' => 0,
'active' => true,
'expires_at' => now()->addYear(),
]);
$user = User::find($metadata['user_id'] ?? null);
if ($user) {
$user->update(['role' => 'tenant_admin']);
}
}
});
}
private function handleInvoicePaid(array $invoice): void
{
$subscription = $invoice['subscription'] ?? null;
$metadata = $subscription['metadata'] ?? [];
if (! isset($metadata['tenant_id'], $metadata['package_id'])) {
return;
}
$tenantId = $metadata['tenant_id'];
$packageId = $metadata['package_id'];
$tenantPackage = TenantPackage::where('tenant_id', $tenantId)
->where('package_id', $packageId)
->where('stripe_subscription_id', $subscription)
->first();
if ($tenantPackage) {
$tenantPackage->update([
'active' => true,
'expires_at' => now()->addYear(),
]);
} else {
TenantPackage::create([
'tenant_id' => $tenantId,
'package_id' => $packageId,
'stripe_subscription_id' => $subscription,
'used_events' => 0,
'active' => true,
'expires_at' => now()->addYear(),
]);
$user = User::find($metadata['user_id'] ?? null);
if ($user) {
$user->update(['role' => 'tenant_admin']);
}
}
PackagePurchase::create([
'package_id' => $packageId,
'type' => 'reseller_subscription',
'provider_id' => 'stripe',
'transaction_id' => $invoice['id'] ?? null,
'price' => isset($invoice['amount_paid']) ? $invoice['amount_paid'] / 100 : 0,
'metadata' => $metadata,
]);
}
}

View File

@@ -55,7 +55,7 @@ class CheckoutController extends Controller
public function register(Request $request)
{
$validator = Validator::make($request->all(), [
'email' => 'required|email|unique:users,email',
'email' => 'required|email|max:255|unique:users,email',
'username' => 'required|string|max:255|unique:users,username',
'password' => ['required', 'confirmed', Password::defaults()],
'first_name' => 'required|string|max:255',

View File

@@ -1,102 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Package;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Stripe\PaymentIntent;
use Stripe\Stripe;
use Symfony\Component\HttpFoundation\Response;
class StripePaymentController extends Controller
{
public function __construct()
{
Stripe::setApiKey(config('services.stripe.secret'));
}
public function createPaymentIntent(Request $request): JsonResponse
{
$request->validate([
'package_id' => 'required|integer|exists:packages,id',
]);
$user = Auth::user();
if (! $user) {
return ApiError::response(
'unauthenticated',
'Nicht authentifiziert',
'Bitte melde dich an, um einen Kauf zu starten.',
Response::HTTP_UNAUTHORIZED
);
}
$tenant = $user->tenant;
if (! $tenant) {
return ApiError::response(
'tenant_not_found',
'Tenant nicht gefunden',
'Für dein Benutzerkonto konnte kein Tenant gefunden werden.',
Response::HTTP_FORBIDDEN
);
}
$package = Package::findOrFail($request->package_id);
// Kostenlose Pakete brauchen kein Payment Intent
if ($package->price <= 0) {
return response()->json([
'type' => 'free',
'message' => 'Kostenloses Paket - kein Payment Intent nötig',
]);
}
try {
$paymentIntent = PaymentIntent::create([
'amount' => (int) ($package->price * 100), // In Cent
'currency' => 'eur',
'metadata' => [
'package_id' => $package->id,
'tenant_id' => $tenant->id,
'user_id' => $user->id,
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
],
'automatic_payment_methods' => [
'enabled' => true,
],
'description' => "Paket: {$package->name}",
'receipt_email' => $user->email,
]);
Log::info('Payment Intent erstellt', [
'payment_intent_id' => $paymentIntent->id,
'package_id' => $package->id,
'tenant_id' => $tenant->id,
'amount' => $package->price,
]);
return response()->json([
'clientSecret' => $paymentIntent->client_secret,
'paymentIntentId' => $paymentIntent->id,
]);
} catch (\Exception $e) {
Log::error('Stripe Payment Intent Fehler', [
'error' => $e->getMessage(),
'package_id' => $request->package_id,
'user_id' => $user->id,
]);
return ApiError::response(
'stripe_payment_error',
'Stripe Fehler',
'Die Zahlung konnte nicht vorbereitet werden.',
Response::HTTP_BAD_REQUEST,
['stripe_message' => $e->getMessage()]
);
}
}
}