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:
@@ -23,7 +23,7 @@ class ViewPurchase extends ViewRecord
|
||||
->visible(fn ($record): bool => ! $record->refunded)
|
||||
->action(function ($record) {
|
||||
$record->update(['refunded' => true]);
|
||||
// TODO: Call Stripe/Paddle API for actual refund
|
||||
// TODO: Call Paddle API for actual refund
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ class PackagePurchasesRelationManager extends RelationManager
|
||||
->label('Anbieter')
|
||||
->options([
|
||||
'paddle' => 'Paddle',
|
||||
'stripe' => 'Stripe',
|
||||
'manual' => 'Manuell',
|
||||
'free' => 'Kostenlos',
|
||||
])
|
||||
@@ -89,7 +88,6 @@ class PackagePurchasesRelationManager extends RelationManager
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'paddle' => 'success',
|
||||
'stripe' => 'info',
|
||||
'manual' => 'gray',
|
||||
'free' => 'success',
|
||||
default => 'gray',
|
||||
@@ -117,7 +115,6 @@ class PackagePurchasesRelationManager extends RelationManager
|
||||
SelectFilter::make('provider')
|
||||
->options([
|
||||
'paddle' => 'Paddle',
|
||||
'stripe' => 'Stripe',
|
||||
'manual' => 'Manuell',
|
||||
'free' => 'Kostenlos',
|
||||
]),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,6 @@ class ContentSecurityPolicy
|
||||
$scriptSources = [
|
||||
"'self'",
|
||||
"'nonce-{$scriptNonce}'",
|
||||
'https://js.stripe.com',
|
||||
'https://js.stripe.network',
|
||||
'https://cdn.paddle.com',
|
||||
'https://global.localizecdn.com',
|
||||
];
|
||||
@@ -51,8 +49,6 @@ class ContentSecurityPolicy
|
||||
|
||||
$connectSources = [
|
||||
"'self'",
|
||||
'https://api.stripe.com',
|
||||
'https://api.stripe.network',
|
||||
'https://api.paddle.com',
|
||||
'https://sandbox-api.paddle.com',
|
||||
'https://checkout.paddle.com',
|
||||
@@ -64,7 +60,6 @@ class ContentSecurityPolicy
|
||||
|
||||
$frameSources = [
|
||||
"'self'",
|
||||
'https://js.stripe.com',
|
||||
'https://checkout.paddle.com',
|
||||
'https://sandbox-checkout.paddle.com',
|
||||
'https://checkout-service.paddle.com',
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EventPurchase;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ValidateStripeWebhookJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $payload;
|
||||
public $sig;
|
||||
public $tries = 3;
|
||||
public $backoff = [60, 300, 600]; // Retry delays
|
||||
|
||||
public function __construct($payload, $sig)
|
||||
{
|
||||
$this->payload = $payload;
|
||||
$this->sig = $sig;
|
||||
}
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$secret = config('services.stripe.webhook');
|
||||
|
||||
if (!$secret) {
|
||||
Log::error('No Stripe webhook secret configured');
|
||||
return;
|
||||
}
|
||||
|
||||
$expectedSig = 'v1=' . hash_hmac('sha256', $this->payload, $secret);
|
||||
|
||||
if (!hash_equals($expectedSig, $this->sig)) {
|
||||
Log::error('Invalid signature in Stripe webhook job');
|
||||
return;
|
||||
}
|
||||
|
||||
$event = json_decode($this->payload, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
Log::error('Invalid JSON in Stripe webhook job: ' . json_last_error_msg());
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event['type'] === 'checkout.session.completed') {
|
||||
$session = $event['data']['object'];
|
||||
$receiptId = $session['id'];
|
||||
|
||||
if (EventPurchase::where('external_receipt_id', $receiptId)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenantId = $session['metadata']['tenant_id'] ?? null;
|
||||
|
||||
if (!$tenantId) {
|
||||
Log::warning('No tenant_id in Stripe metadata job', ['receipt_id' => $receiptId]);
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
|
||||
if (!$tenant) {
|
||||
Log::error('Tenant not found in Stripe webhook job', ['tenant_id' => $tenantId]);
|
||||
return;
|
||||
}
|
||||
|
||||
$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(),
|
||||
]);
|
||||
|
||||
});
|
||||
|
||||
Log::info('Processed Stripe purchase via job', ['receipt_id' => $receiptId, 'tenant_id' => $tenantId]);
|
||||
} else {
|
||||
Log::info('Unhandled Stripe event in job', ['type' => $event['type']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,6 @@ class CheckoutSession extends Model
|
||||
|
||||
public const PROVIDER_NONE = 'none';
|
||||
|
||||
public const PROVIDER_STRIPE = 'stripe';
|
||||
|
||||
public const PROVIDER_PADDLE = 'paddle';
|
||||
|
||||
public const PROVIDER_FREE = 'free';
|
||||
|
||||
@@ -28,7 +28,6 @@ use App\Listeners\Packages\QueueTenantPackageExpiredNotification;
|
||||
use App\Listeners\Packages\QueueTenantPackageExpiringNotification;
|
||||
use App\Notifications\UploadPipelineFailed;
|
||||
use App\Services\Checkout\CheckoutAssignmentService;
|
||||
use App\Services\Checkout\CheckoutPaymentService;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Security\PhotoSecurityScanner;
|
||||
use App\Services\Storage\EventStorageManager;
|
||||
@@ -56,7 +55,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
$this->app->singleton(CheckoutSessionService::class);
|
||||
$this->app->singleton(CheckoutAssignmentService::class);
|
||||
$this->app->singleton(CheckoutPaymentService::class);
|
||||
$this->app->singleton(EventStorageManager::class);
|
||||
$this->app->singleton(StorageHealthService::class);
|
||||
$this->app->singleton(PhotoSecurityScanner::class);
|
||||
|
||||
@@ -11,13 +11,13 @@ use App\Models\PackagePurchase;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Ops\PurchaseCreated;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Notifications\Ops\PurchaseCreated;
|
||||
|
||||
class CheckoutAssignmentService
|
||||
{
|
||||
@@ -62,13 +62,11 @@ class CheckoutAssignmentService
|
||||
$providerReference = $options['provider_reference']
|
||||
?? $metadata['paddle_transaction_id'] ?? null
|
||||
?? $metadata['paddle_checkout_id'] ?? null
|
||||
?? $session->stripe_payment_intent_id
|
||||
?? CheckoutSession::PROVIDER_FREE;
|
||||
|
||||
$providerName = $options['provider']
|
||||
?? $session->provider
|
||||
?? ($metadata['paddle_transaction_id'] ?? $metadata['paddle_checkout_id'] ? CheckoutSession::PROVIDER_PADDLE : null)
|
||||
?? ($session->stripe_payment_intent_id ? CheckoutSession::PROVIDER_STRIPE : null)
|
||||
?? CheckoutSession::PROVIDER_FREE;
|
||||
|
||||
$purchase = PackagePurchase::updateOrCreate(
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Checkout;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Tenant;
|
||||
use LogicException;
|
||||
|
||||
class CheckoutPaymentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
private readonly CheckoutAssignmentService $assignment,
|
||||
) {}
|
||||
|
||||
public function initialiseStripe(CheckoutSession $session, array $payload = []): array
|
||||
{
|
||||
if ($session->provider !== CheckoutSession::PROVIDER_STRIPE) {
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_STRIPE);
|
||||
}
|
||||
|
||||
// TODO: integrate Stripe PaymentIntent creation and return client_secret + publishable key
|
||||
return [
|
||||
'session_id' => $session->id,
|
||||
'status' => $session->status,
|
||||
'message' => 'Stripe integration pending implementation.',
|
||||
];
|
||||
}
|
||||
|
||||
public function confirmStripe(CheckoutSession $session, array $payload = []): CheckoutSession
|
||||
{
|
||||
if ($session->provider !== CheckoutSession::PROVIDER_STRIPE) {
|
||||
throw new LogicException('Cannot confirm Stripe payment on a non-Stripe session.');
|
||||
}
|
||||
|
||||
// TODO: verify PaymentIntent status with Stripe SDK and update session metadata
|
||||
$this->sessions->markProcessing($session);
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
public function finaliseFree(CheckoutSession $session): CheckoutSession
|
||||
{
|
||||
if ($session->provider !== CheckoutSession::PROVIDER_FREE) {
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_FREE);
|
||||
}
|
||||
|
||||
$this->sessions->markProcessing($session);
|
||||
$this->assignment->finalise($session, ['source' => 'free']);
|
||||
|
||||
return $this->sessions->markCompleted($session);
|
||||
}
|
||||
|
||||
public function attachTenantAndResume(CheckoutSession $session, Tenant $tenant): CheckoutSession
|
||||
{
|
||||
$this->sessions->attachTenant($session, $tenant);
|
||||
$this->sessions->refreshExpiration($session);
|
||||
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
@@ -68,9 +68,6 @@ class CheckoutSessionService
|
||||
$session->amount_discount = 0;
|
||||
$session->provider = CheckoutSession::PROVIDER_NONE;
|
||||
$session->status = CheckoutSession::STATUS_DRAFT;
|
||||
$session->stripe_payment_intent_id = null;
|
||||
$session->stripe_customer_id = null;
|
||||
$session->stripe_subscription_id = null;
|
||||
$session->paddle_checkout_id = null;
|
||||
$session->paddle_transaction_id = null;
|
||||
$session->provider_metadata = [];
|
||||
@@ -117,7 +114,6 @@ class CheckoutSessionService
|
||||
$provider = strtolower($provider);
|
||||
|
||||
if (! in_array($provider, [
|
||||
CheckoutSession::PROVIDER_STRIPE,
|
||||
CheckoutSession::PROVIDER_PADDLE,
|
||||
CheckoutSession::PROVIDER_FREE,
|
||||
], true)) {
|
||||
|
||||
@@ -25,63 +25,6 @@ class CheckoutWebhookService
|
||||
private readonly GiftVoucherService $giftVouchers,
|
||||
) {}
|
||||
|
||||
public function handleStripeEvent(array $event): bool
|
||||
{
|
||||
$eventType = $event['type'] ?? null;
|
||||
$intent = $event['data']['object'] ?? null;
|
||||
|
||||
if (! $eventType || ! is_array($intent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! str_starts_with($eventType, 'payment_intent.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$intentId = $intent['id'] ?? null;
|
||||
|
||||
if (! $intentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$session = $this->locateStripeSession($intent);
|
||||
|
||||
if (! $session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lock = Cache::lock("checkout:webhook:stripe:{$intentId}", 30);
|
||||
|
||||
if (! $lock->get()) {
|
||||
Log::info('[CheckoutWebhook] Stripe intent lock busy', [
|
||||
'intent_id' => $intentId,
|
||||
'session_id' => $session->id,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$session->forceFill([
|
||||
'stripe_payment_intent_id' => $session->stripe_payment_intent_id ?: $intentId,
|
||||
'provider' => CheckoutSession::PROVIDER_STRIPE,
|
||||
])->save();
|
||||
|
||||
$metadata = [
|
||||
'stripe_last_event' => $eventType,
|
||||
'stripe_last_event_id' => $event['id'] ?? null,
|
||||
'stripe_intent_status' => $intent['status'] ?? null,
|
||||
'stripe_last_update_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
$this->mergeProviderMetadata($session, $metadata);
|
||||
|
||||
return $this->applyStripeIntent($session, $eventType, $intent);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
public function handlePaddleEvent(array $event): bool
|
||||
{
|
||||
$eventType = $event['event_type'] ?? null;
|
||||
@@ -158,51 +101,6 @@ class CheckoutWebhookService
|
||||
}
|
||||
}
|
||||
|
||||
protected function applyStripeIntent(CheckoutSession $session, string $eventType, array $intent): bool
|
||||
{
|
||||
switch ($eventType) {
|
||||
case 'payment_intent.processing':
|
||||
case 'payment_intent.amount_capturable_updated':
|
||||
$this->sessions->markProcessing($session, [
|
||||
'stripe_intent_status' => $intent['status'] ?? null,
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
||||
case 'payment_intent.requires_action':
|
||||
$reason = $intent['next_action']['type'] ?? 'requires_action';
|
||||
$this->sessions->markRequiresCustomerAction($session, $reason);
|
||||
|
||||
return true;
|
||||
|
||||
case 'payment_intent.payment_failed':
|
||||
$failure = $intent['last_payment_error']['message'] ?? 'payment_failed';
|
||||
$this->sessions->markFailed($session, $failure);
|
||||
|
||||
return true;
|
||||
|
||||
case 'payment_intent.succeeded':
|
||||
if ($session->status !== CheckoutSession::STATUS_COMPLETED) {
|
||||
$this->sessions->markProcessing($session, [
|
||||
'stripe_intent_status' => $intent['status'] ?? null,
|
||||
]);
|
||||
|
||||
$this->assignment->finalise($session, [
|
||||
'source' => 'stripe_webhook',
|
||||
'stripe_payment_intent_id' => $intent['id'] ?? null,
|
||||
'stripe_charge_id' => $this->extractStripeChargeId($intent),
|
||||
]);
|
||||
|
||||
$this->sessions->markCompleted($session, now());
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function applyPaddleEvent(CheckoutSession $session, string $eventType, array $data): bool
|
||||
{
|
||||
$status = strtolower((string) ($data['status'] ?? ''));
|
||||
@@ -417,30 +315,6 @@ class CheckoutWebhookService
|
||||
$session->save();
|
||||
}
|
||||
|
||||
protected function locateStripeSession(array $intent): ?CheckoutSession
|
||||
{
|
||||
$intentId = $intent['id'] ?? null;
|
||||
|
||||
if ($intentId) {
|
||||
$session = CheckoutSession::query()
|
||||
->where('stripe_payment_intent_id', $intentId)
|
||||
->first();
|
||||
|
||||
if ($session) {
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
|
||||
$metadata = $intent['metadata'] ?? [];
|
||||
$sessionId = $metadata['checkout_session_id'] ?? null;
|
||||
|
||||
if ($sessionId) {
|
||||
return CheckoutSession::find($sessionId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function isGiftVoucherEvent(array $data): bool
|
||||
{
|
||||
$metadata = $data['metadata'] ?? [];
|
||||
@@ -498,14 +372,4 @@ class CheckoutWebhookService
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function extractStripeChargeId(array $intent): ?string
|
||||
{
|
||||
$charges = $intent['charges']['data'] ?? null;
|
||||
if (is_array($charges) && count($charges) > 0) {
|
||||
return $charges[0]['id'] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user