where('stripe_event_id', $event['id'])->first(); if ($existing !== null) { $this->resume($existing); return null; } $customer = $this->resolveCustomer($event); try { $customer->ensureUser(); // portal login (also enables admin impersonation) } catch (IdentityCollisionException) { // Same principle as openContract() below: the order is the record // that money changed hands, so a paid order still gets recorded // and provisioned. Only the colliding portal LOGIN is withheld — // R21 — until an operator resolves the address conflict by hand // (rename the operator, or the customer). Until then this // customer has no self-service sign-in and cannot be // impersonated either; both call ensureUser() the same way. Log::error('Portal login withheld: this customer email already belongs to an operator account.', [ 'customer_id' => $customer->id, 'email' => $customer->email, ]); } 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'], // How much of `amount_cents` was the one-off setup fee. Kept // beside the total rather than subtracted from it: a withdrawal // sends back everything that was taken and the confirmation // mail states everything that was taken, while the register and // the invoice need the package and the fee apart — see // Order::packageChargedCents(). 'setup_fee_cents' => (int) ($event['setup_fee_cents'] ?? 0), 'currency' => $event['currency'], 'datacenter' => $event['datacenter'], 'stripe_event_id' => $event['id'], 'stripe_subscription_id' => $event['stripe_subscription_id'] ?? null, // Kept so a withdrawal has something to refund against; see // App\Actions\WithdrawContract. 'stripe_payment_intent_id' => $event['stripe_payment_intent_id'] ?? null, 'stripe_invoice_id' => $event['stripe_invoice_id'] ?? null, // Stamped only where the consumer actually gave it. Nothing // turns on it any more: a withdrawal refunds the whole // amount whether it is there or not, because the owner has // decided not to charge for the days the cloud ran. Kept as // a record of what the customer was shown and ticked. 'immediate_start_consent_at' => ($event['immediate_start_consent'] ?? false) === true ? now() : 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 $this->confirmByMail($order, $customer); return $order; } /** * Invoice the payment, and tell the customer once. * * Both halves are idempotent and live in invoiceOnce() and mailInvoiceOnce() * below, because this is not the only caller any more: resume() has to be able * to finish exactly this work when a crash cut it off, and two copies of "have * we invoiced this yet" would be two answers. * * Sent here rather than when provisioning finishes: those are minutes apart * at best and can be much longer, and somebody who has just been charged * and heard nothing assumes the worst about both the charge and the * product. CloudReady announces the end; this announces the beginning. * * After the transaction, never inside it: a queued job can be picked up by * a worker before the commit lands, and it would then read an order that * does not exist yet. * * Never allowed to fail the webhook. Stripe retries anything that is not a * 2xx, and a retry here would re-enter provisioning — a second machine * built — over a mail server or a missing VAT number. */ private function confirmByMail(Order $order, Customer $customer): void { $invoice = $this->invoiceOnce($order, $customer); if ($invoice !== null) { $this->mailInvoiceOnce($invoice, $customer); return; } $address = $customer->email; if (! is_string($address) || $address === '') { return; } // No invoice, and there could not be one: an incomplete company profile, // or a purchase that was a full gift. The confirmation says what it can. // // Sent from HERE only, never from resume(). It has nothing to stamp — an // order carries no `sent_at` — so a redelivery could not tell that it had // already gone out, and Stripe redelivers until it gets a 2xx. The invoice // mail is the one a crash must not lose, and that one is stamped. try { Mail::to($address)->queue(new OrderConfirmationMail($order, (string) $customer->name)); } catch (Throwable $e) { Log::warning('Could not queue the purchase confirmation', [ 'order' => $order->id, 'exception' => $e->getMessage(), ]); } } /** * The document this purchase owes, issued exactly once however often we are * asked. * * The guard is the invoice already filed against this order, because an * invoice number comes out of a gapless series and can never be handed out * twice. Cancellations are excluded from the search: a Storno carries the same * `order_id` as the document it takes back, so counting one would leave a * withdrawn purchase looking as though it had been invoiced by its own * cancellation. * * Not a database-level guard, and it cannot be one — `invoices.order_id` is * legitimately shared by an invoice and its Storno, so it can never be unique. * What this closes is the case that actually happens: Stripe redelivering the * same event, one delivery after another. */ private function invoiceOnce(Order $order, Customer $customer): ?Invoice { $existing = Invoice::query() ->where('order_id', $order->id) ->whereNull('cancels_invoice_id') ->orderBy('id') ->first(); if ($existing !== null) { return $existing; } try { // Refuses while the company details are incomplete, which is the // correct outcome — an invoice without a registered name or a VAT // number is not a valid invoice, and issuing one consumes a number // that can never be handed out again. The purchase is unaffected: // the order stands, the machine gets built, and the invoice can be // issued from the console once the details are there. return app(IssueInvoice::class)->forOrders($customer, collect([$order])); } catch (Throwable $e) { Log::warning('Could not issue an invoice for this order', [ 'order' => $order->id, 'exception' => $e->getMessage(), ]); return null; } } /** * Hand the invoice to the mailer, once. * * `sent_at` is the stamp, the same one App\Actions\IssueStripeInvoice uses, * and it is written only after the mailer has accepted the message — so a * redelivery arriving after the document was written but before the mail went * out finishes the job, and one arriving afterwards does neither twice. * * ONE mail, not two. The invoice is issued at the moment the money lands, so * an order confirmation and an invoice mail would otherwise go out in the same * second for the same event. The invoice mail says everything the confirmation * said and carries the document, so it replaces it whenever there is one. */ private function mailInvoiceOnce(Invoice $invoice, Customer $customer): void { $address = $customer->email; if (! is_string($address) || $address === '' || $invoice->sent_at !== null) { return; } try { Mail::to($address)->queue(new InvoiceMail($invoice, (string) $customer->name)); $invoice->update(['sent_at' => now()]); } catch (Throwable $e) { Log::warning('Could not queue the invoice mail for this purchase', [ 'order' => $invoice->order_id, 'invoice' => $invoice->number, 'exception' => $e->getMessage(), ]); } } /** * 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. * * ## The invoice is the other half, and it used to be the half that was lost * * This returned as soon as a contract existed, and the only production call to * IssueInvoice::forOrders() is in confirmByMail() — which runs AFTER the * order commits. A worker killed in between left a paid purchase with no * invoice, for good and in silence: the next redelivery found the contract * already open, returned here, and nothing anywhere sweeps for an uninvoiced * order. So the invoice and its mail are finished here too, on every * redelivery, and the guards in invoiceOnce()/mailInvoiceOnce() are what make * "however many times Stripe asks" come out as one invoice, one number and * one mail. */ private function resume(Order $order): void { if (! $order->subscription()->exists()) { $this->openContract($order); if ($order->subscription()->exists()) { $this->reviveRunStrandedWithoutAContract($order); } } $customer = $order->customer; if ($customer === null) { return; } $invoice = $this->invoiceOnce($order, $customer); if ($invoice !== null) { $this->mailInvoiceOnce($invoice, $customer); } } /** * 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. * * `customer_type` is deliberately NOT set here, and that is a decision * rather than an omission. Nobody in this code path has asked the question: * a Stripe event carries a name, an address and a payment, and none of those * says whether the person buying is a consumer or a business. Writing either * answer would be inventing one — so the column stays NULL, which every * reader treats as a consumer, which is the protective answer. Ordinarily * there is nothing to invent: the buyer signed up first and was asked then * (App\Actions\Fortify\CreateNewUser). This branch is the customer whose * Stripe email differs from the one they registered with, and the operator * or the customer can record the answer afterwards in the portal settings. * * @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(); } } }