where('stripe_event_id', $event['id'])->first(); if ($existing !== null) { $this->resume($existing); return null; } $customer = $this->resolveCustomer($event); $customer->ensureUser(); // portal login (also enables admin impersonation) try { [$order, $run] = DB::transaction(function () use ($event, $customer) { $order = Order::create([ 'customer_id' => $customer->id, 'plan' => $event['plan'], // Set once Stripe checkout carries it (phase 5). Null means // "whatever is on sale when the webhook lands", which is // what this did before the column existed. 'plan_version_id' => $event['plan_version_id'] ?? null, 'amount_cents' => $event['amount_cents'], 'currency' => $event['currency'], 'datacenter' => $event['datacenter'], 'stripe_event_id' => $event['id'], 'stripe_subscription_id' => $event['stripe_subscription_id'] ?? null, 'status' => 'paid', ]); $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) { // A concurrent delivery won the race. Both requests will answer // Stripe with a 2xx, so this is the last look anyone takes at this // payment — if the winner then dies before opening the contract, no // retry is coming to fix it. So finish its work rather than just // stepping aside. The unique index on subscriptions.order_id keeps // both from opening one. $winner = Order::query()->where('stripe_event_id', $event['id'])->first(); if ($winner !== null) { $this->resume($winner); } return null; } $this->openContract($order); AdvanceRunJob::dispatch($run->uuid); // after commit return $order; } /** * Finish what a crash interrupted. * * The order commits before the contract is opened, so that a failure there * can never erase the record of a payment — but that leaves a window in * which a paid order exists with no contract and nothing running. Stripe * retries until it gets a 2xx, so a retry is exactly the chance to close * that window. Simply returning "already processed" would strand a paying * customer permanently, because no later webhook would ever look again. * * Safe to run on every duplicate: opening a contract is idempotent. A run * that was merely never dispatched needs no nudge from here — the scheduler * tick sweeps pending runs. A run that already ran and FAILED for want of * the contract does, because nothing sweeps failed runs. */ private function resume(Order $order): void { if ($order->subscription()->exists()) { return; } $this->openContract($order); if ($order->subscription()->exists()) { $this->reviveRunStrandedWithoutAContract($order); } } /** * Restart a run that failed only because the contract was missing. * * Narrow on purpose. `no_subscription` is returned before anything is built * — the run never got past validating the order or reserving resources — so * there is nothing half-made to trip over. A run that failed for any other * reason failed at something a contract does not fix, and restarting it * would just repeat whatever went wrong. */ private function reviveRunStrandedWithoutAContract(Order $order): void { $run = $order->runs()->where('pipeline', 'customer')->latest('id')->first(); if ($run?->status !== ProvisioningRun::STATUS_FAILED || ! str_contains((string) $run->error, 'no_subscription')) { return; } // onProvisioningFailed() marked the order failed on the way down. $order->update(['status' => 'paid']); $run->update([ 'status' => ProvisioningRun::STATUS_PENDING, 'current_step' => 0, 'attempt' => 0, 'next_attempt_at' => null, 'error' => null, ]); AdvanceRunJob::dispatch($run->uuid); Log::info('Restarted a run that was stranded without a contract.', [ 'order_id' => $order->id, 'run' => $run->uuid, ]); } /** * Freeze what they bought, while the catalogue still says what they were * shown. * * Deliberately AFTER the order is committed, and deliberately swallowing * its failure. The order is the record that money changed hands: if opening * the contract could roll it back, an unsellable plan, a missing price or * any unforeseen fault would erase the evidence of a payment we have * already taken — and Stripe, seeing no 2xx, would retry a webhook that can * never succeed. A missing contract is recoverable and loud: the run stops * at ValidateOrder with `no_subscription`, in the operator's face. * * Two cases are expected to land here: a plan the catalogue cannot sell, * and a payment in a currency it cannot price — freezing a EUR price onto a * CHF payment would put the contract and the payment in permanent * disagreement. */ private function openContract(Order $order): void { try { // A checkout that recorded its version is owed THAT version, even // if the window has closed or the plan has been withdrawn since — // they paid for what they were shown. Only a purchase with no // recorded version has to ask what is on sale now. $deliverable = $order->plan_version_id !== null ? app(PlanCatalogue::class)->isDeliverable($order->plan, $order->plan_version_id) : Subscription::knowsPlan($order->plan); $sellable = $deliverable && strtoupper((string) $order->currency) === Subscription::catalogueCurrency(); if (! $sellable) { Log::warning('No contract opened: the catalogue cannot sell this order.', [ 'order_id' => $order->id, 'plan' => $order->plan, 'currency' => $order->currency, ]); return; } $subscription = ($this->openSubscription)($order); // Anything Stripe sent about this contract before it existed — // a renewal, a failure, a cancellation — applied now, in the order // they were raised. $this->billing->replayHeldFor($subscription); } catch (Throwable $e) { Log::error('Failed to open a contract for a paid order.', [ 'order_id' => $order->id, 'plan' => $order->plan, 'error' => $e->getMessage(), ]); } } /** * 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(); } } }