CluPilotCloud/app/Actions/StartCustomerProvisioning.php

437 lines
19 KiB
PHP

<?php
namespace App\Actions;
use App\Exceptions\IdentityCollisionException;
use App\Mail\InvoiceMail;
use App\Mail\OrderConfirmationMail;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\IssueInvoice;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Turns a paid Stripe event into a customer + order + provisioning run. The
* order's unique stripe_event_id is the idempotency key: a duplicate webhook
* never starts a second run.
*/
class StartCustomerProvisioning
{
public function __construct(
private OpenSubscription $openSubscription,
private ApplyStripeBillingEvent $billing,
) {}
/**
* @param array{id:string,email:string,name:?string,stripe_customer_id:?string,plan:string,datacenter:string,amount_cents:int,setup_fee_cents?:int,currency:string,immediate_start_consent?:bool} $event
*/
public function fromStripeEvent(array $event): ?Order
{
$existing = Order::query()->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();
}
}
}