CluPilotCloud/app/Actions/GrantSubscription.php

117 lines
4.4 KiB
PHP

<?php
namespace App\Actions;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Gives a customer a package without a payment.
*
* A grant is an order without money, not a second code path: this creates the
* exact Order → ProvisioningRun → Subscription rows a Stripe purchase does —
* same pipeline, same OpenSubscription — with `stripe_subscription_id` left
* null and `price_cents` set to whatever the customer actually pays (0 for a
* full gift, less than the catalogue price for a discount). Provisioning,
* DowngradeCheck, seats, quotas, cancellation: all of it reads an ordinary
* Subscription row afterwards and never learns this one was free.
*/
class GrantSubscription
{
public function __construct(private OpenSubscription $openSubscription) {}
/**
* @param array{quota_gb?:int,seats?:int,traffic_gb?:int} $bonus Beyond what the plan tier already includes.
*/
public function __invoke(
Customer $customer,
Operator $grantedBy,
string $plan,
string $term,
string $datacenter,
int $priceCents,
array $bonus = [],
?string $note = null,
?Carbon $until = null,
): Subscription {
if (! Subscription::knowsPlan($plan)) {
throw new RuntimeException("Plan '{$plan}' is not sellable, so it cannot be granted either.");
}
$version = app(PlanCatalogue::class)->currentVersion($plan);
$cataloguePrice = $version->priceFor($term);
if ($cataloguePrice === null) {
throw new RuntimeException("Plan '{$plan}' is not sold on a {$term} term.");
}
// A "grant" that charges more than the plan actually costs is not a
// grant — that path already exists, and it goes through Stripe.
if ($priceCents < 0 || $priceCents > $cataloguePrice->amount_cents) {
throw new RuntimeException('A grant cannot charge more than the catalogue price.');
}
[$run, $subscription] = DB::transaction(function () use (
$customer, $grantedBy, $plan, $term, $datacenter, $priceCents, $bonus, $note, $until, $cataloguePrice,
) {
$order = Order::create([
'customer_id' => $customer->id,
'plan' => $plan,
'type' => 'new',
'amount_cents' => $priceCents,
'currency' => Subscription::catalogueCurrency(),
'datacenter' => $datacenter,
// Never Stripe's: this purchase never went through Stripe at
// all, which is the whole point, and is also what tells
// IssueInvoice and the revenue figures this row apart from an
// ordinary (possibly also zero-priced) checkout.
'stripe_event_id' => null,
'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,
'context' => ['branding' => $customer->brandingResolved()],
]);
$overrides = array_filter([
'price_cents' => $priceCents,
'quota_gb' => $bonus['quota_gb'] ?? null,
'seats' => $bonus['seats'] ?? null,
'traffic_gb' => $bonus['traffic_gb'] ?? null,
], fn ($value) => $value !== null);
$overrides += [
'granted_by' => $grantedBy->id,
'granted_at' => now(),
'grant_note' => $note,
'granted_until' => $until,
'catalogue_price_cents' => $cataloguePrice->amount_cents,
];
$subscription = ($this->openSubscription)($order, $term, $overrides);
return [$run, $subscription];
});
// After the commit, never inside it — a worker can pick the run up
// before the transaction lands (see StartCustomerProvisioning).
AdvanceRunJob::dispatch($run->uuid);
return $subscription;
}
}