117 lines
4.4 KiB
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;
|
|
}
|
|
}
|