437 lines
19 KiB
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();
|
|
}
|
|
}
|
|
}
|