246 lines
10 KiB
PHP
246 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Actions;
|
|
|
|
use App\Exceptions\IdentityCollisionException;
|
|
use App\Models\Customer;
|
|
use App\Models\Order;
|
|
use App\Models\ProvisioningRun;
|
|
use App\Models\Subscription;
|
|
use App\Provisioning\Jobs\AdvanceRunJob;
|
|
use App\Services\Billing\PlanCatalogue;
|
|
use Illuminate\Database\UniqueConstraintViolationException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
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,currency:string} $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'],
|
|
'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();
|
|
}
|
|
}
|
|
}
|