where('stripe_event_id', $event['id'])->exists()) { return null; // already processed } $customer = $this->resolveCustomer($event); $customer->ensureUser(); // portal login (also enables admin impersonation) $openSubscription = $this->openSubscription; try { [$order, $run] = DB::transaction(function () use ($event, $customer, $openSubscription) { $order = Order::create([ 'customer_id' => $customer->id, 'plan' => $event['plan'], 'amount_cents' => $event['amount_cents'], 'currency' => $event['currency'], 'datacenter' => $event['datacenter'], 'stripe_event_id' => $event['id'], 'status' => 'paid', ]); // Freeze what they bought, now, while the catalogue still says // what they were shown. // // Two cases are deliberately left WITHOUT a contract: a plan the // catalogue does not know, and a payment in a currency the // catalogue cannot express — freezing a EUR price onto a CHF // payment would put the contract and the payment in permanent // disagreement. Both still record the order, so the payment is // traceable, and the run then fails visibly at ValidateOrder. // Throwing here instead would roll the order back and leave // Stripe retrying a webhook that can never succeed. $sellable = Subscription::knowsPlan($order->plan) && strtoupper((string) $order->currency) === Subscription::catalogueCurrency(); if ($sellable) { $openSubscription($order); } $run = ProvisioningRun::create([ 'subject_type' => Order::class, 'subject_id' => $order->id, 'pipeline' => 'customer', 'status' => ProvisioningRun::STATUS_PENDING, 'current_step' => 0, // Snapshot resolved branding so retries apply identical inputs // (unset customer → CluPilot defaults, resolved once here). 'context' => ['branding' => $customer->brandingResolved()], ]); return [$order, $run]; }); } catch (UniqueConstraintViolationException) { return null; // concurrent duplicate webhook } AdvanceRunJob::dispatch($run->uuid); // after commit return $order; } /** * Find or create the customer, race-safe against the unique email index so a * concurrent first-time purchase can't create two customers for one email. * * @param array{email:string,name:?string,stripe_customer_id:?string} $event */ private function resolveCustomer(array $event): Customer { try { return Customer::query()->firstOrCreate( ['email' => $event['email']], ['name' => $event['name'] ?? $event['email'], 'stripe_customer_id' => $event['stripe_customer_id'] ?? null], ); } catch (UniqueConstraintViolationException) { return Customer::query()->where('email', $event['email'])->firstOrFail(); } } }