Let a subscription or add-on be opened for free, with who and why on the row

A grant is an order without money, not a second code path: GrantSubscription
and GrantAddon create the same Order/Subscription/SubscriptionAddon rows a
paid purchase does, with stripe_subscription_id left null and price_cents set
to whatever the customer actually pays. OpenSubscription and BookAddon gained
an optional overrides parameter so the price and provenance (who granted it,
when, a note, an optional end date, and the catalogue price for legibility)
land on the same snapshot every real purchase goes through, not a duplicate.

New customers.grant_plan capability, Owner-only like secrets.manage — giving
away service is exactly the kind of blast radius that precedent reserves for
the Owner.
feat/granted-plans
nexxo 2026-07-29 12:41:06 +02:00
parent 696c8834b8
commit 602602864a
13 changed files with 612 additions and 14 deletions

View File

@ -18,12 +18,17 @@ use RuntimeException;
* exactly the reason the plan's price does: they agreed to a figure. What they
* have NOT booked stays on the live catalogue that is a sale still to be
* made, at whatever it costs now.
*
* `$overrides` exists for GrantAddon: a granted module is booked through this
* same action, with its price replaced by what the customer actually pays and
* its provenance stamped on the row. Empty for every ordinary booking.
*/
class BookAddon
{
public function __construct(private RecordCommercialEvent $record) {}
public function __invoke(Subscription $subscription, string $addonKey, int $quantity = 1, ?Order $order = null): SubscriptionAddon
/** @param array<string, mixed> $overrides */
public function __invoke(Subscription $subscription, string $addonKey, int $quantity = 1, ?Order $order = null, array $overrides = []): SubscriptionAddon
{
$price = app(AddonCatalogue::class)->priceCents($addonKey);
@ -36,7 +41,7 @@ class BookAddon
}
try {
return $this->book($subscription, $addonKey, $quantity, $order, $price);
return $this->book($subscription, $addonKey, $quantity, $order, $price, $overrides);
} catch (UniqueConstraintViolationException) {
// A concurrent retry won. The unique index on (order_id, addon_key)
// is what actually enforces "one order books one module" — the
@ -49,12 +54,13 @@ class BookAddon
}
}
private function book(Subscription $subscription, string $addonKey, int $quantity, ?Order $order, int $price): SubscriptionAddon
/** @param array<string, mixed> $overrides */
private function book(Subscription $subscription, string $addonKey, int $quantity, ?Order $order, int $price, array $overrides = []): SubscriptionAddon
{
// The booking and its entry in the register commit together: if the
// record could fail afterwards, retrying the same order would find the
// add-on already there and skip the event for good.
return DB::transaction(function () use ($subscription, $addonKey, $quantity, $order, $price) {
return DB::transaction(function () use ($subscription, $addonKey, $quantity, $order, $price, $overrides) {
// Idempotent against a retried webhook: one order books one module.
if ($order !== null) {
$existing = SubscriptionAddon::query()
@ -67,7 +73,7 @@ class BookAddon
}
}
$addon = SubscriptionAddon::create([
$addon = SubscriptionAddon::create(array_merge([
'subscription_id' => $subscription->id,
'order_id' => $order?->id,
'addon_key' => $addonKey,
@ -75,13 +81,13 @@ class BookAddon
'currency' => Subscription::catalogueCurrency(),
'quantity' => $quantity,
'booked_at' => now(),
]);
], $overrides));
($this->record)(
event: SubscriptionRecord::EVENT_ADDON_BOOKED,
subscription: $subscription,
netCents: $addon->monthlyCents(),
extra: ['addon' => ['key' => $addonKey, 'quantity' => $quantity, 'price_cents' => $price]],
extra: ['addon' => ['key' => $addonKey, 'quantity' => $quantity, 'price_cents' => $addon->price_cents]],
order: $order,
stripe: ['event' => $order?->stripe_event_id],
// What was actually charged for the module, on the same terms

View File

@ -0,0 +1,70 @@
<?php
namespace App\Actions;
use App\Models\Operator;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonCatalogue;
use Illuminate\Support\Carbon;
use RuntimeException;
/**
* Gives a customer a module without a payment "ein Plugin schenken".
*
* Books through the same BookAddon a paid order uses, on a synthetic Order
* (no Stripe id, price set to what the customer actually pays) so the module
* ends up frozen on `subscription_addons` exactly like a real booking would.
*/
class GrantAddon
{
public function __construct(private BookAddon $bookAddon) {}
public function __invoke(
Subscription $subscription,
Operator $grantedBy,
string $addonKey,
int $priceCents,
int $quantity = 1,
?string $note = null,
?Carbon $until = null,
): SubscriptionAddon {
$cataloguePrice = app(AddonCatalogue::class)->priceCents($addonKey);
if ($cataloguePrice === null) {
throw new RuntimeException("Unknown add-on: {$addonKey}");
}
if ($priceCents < 0 || $priceCents > $cataloguePrice) {
throw new RuntimeException('A grant cannot charge more than the catalogue price.');
}
// Vestigial for a module — nothing is provisioned at a datacenter for
// it — but the column is not nullable, and the contract's own order
// already carries the right one.
$datacenter = $subscription->order?->datacenter ?? 'fsn';
$order = Order::create([
'customer_id' => $subscription->customer_id,
'plan' => $subscription->plan,
'type' => 'addon',
'addon_key' => $addonKey,
'amount_cents' => $priceCents,
'currency' => Subscription::catalogueCurrency(),
'datacenter' => $datacenter,
'stripe_event_id' => null,
'stripe_subscription_id' => null,
'status' => 'paid',
]);
return ($this->bookAddon)($subscription, $addonKey, $quantity, $order, [
'price_cents' => $priceCents,
'granted_by' => $grantedBy->id,
'granted_at' => now(),
'grant_note' => $note,
'granted_until' => $until,
'catalogue_price_cents' => $cataloguePrice,
]);
}
}

View File

@ -0,0 +1,116 @@
<?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;
}
}

View File

@ -24,12 +24,20 @@ use Illuminate\Support\Facades\DB;
* records what was charged per event, and Stripe's invoice is the authority for
* the amount. Copying a gross total into this net field would silently corrupt
* every pro-rata calculation that reads it.
*
* `$overrides` exists for exactly one caller, GrantSubscription: a grant opens
* a contract through this same action rather than a second one, but needs to
* set what the customer actually pays (0, or less than the catalogue price)
* and stamp who granted it. Passed straight through to Subscription::create()
* on top of the catalogue snapshot empty for every ordinary purchase, which
* is the whole reason this stays a parameter and not a new method.
*/
class OpenSubscription
{
public function __construct(private RecordCommercialEvent $record) {}
public function __invoke(Order $order, string $term = Subscription::TERM_MONTHLY): Subscription
/** @param array<string, mixed> $overrides */
public function __invoke(Order $order, string $term = Subscription::TERM_MONTHLY, array $overrides = []): Subscription
{
// A retried webhook must not open a second contract for one purchase.
$existing = Subscription::query()->where('order_id', $order->id)->first();
@ -44,10 +52,11 @@ class OpenSubscription
// record could fail after the subscription is in, the next webhook
// retry would find the contract, return early, and leave a paid sale
// permanently missing from the evidence.
return DB::transaction(fn () => $this->open($order, $term, $start));
return DB::transaction(fn () => $this->open($order, $term, $start, $overrides));
}
private function open(Order $order, string $term, Carbon $start): Subscription
/** @param array<string, mixed> $overrides */
private function open(Order $order, string $term, Carbon $start, array $overrides = []): Subscription
{
$subscription = Subscription::create(array_merge(
// The version the order carries, when the checkout recorded one:
@ -67,6 +76,9 @@ class OpenSubscription
: $start->copy()->addMonth(),
'status' => 'active',
],
// Last, so a grant's price and provenance win over the catalogue
// snapshot above rather than the other way round.
$overrides,
));
// The sale, entered in the register the moment it happens. Written from

View File

@ -3,8 +3,9 @@
namespace App\Models;
use App\Models\Concerns\HasUuid;
use App\Services\Billing\TaxTreatment;
use App\Provisioning\Contracts\ProvisioningSubject;
use App\Services\Billing\TaxTreatment;
use Database\Factories\OrderFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -13,7 +14,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
class Order extends Model implements ProvisioningSubject
{
/** @use HasFactory<\Database\Factories\OrderFactory> */
/** @use HasFactory<OrderFactory> */
use HasFactory, HasUuid;
protected $fillable = [
@ -91,6 +92,32 @@ class Order extends Model implements ProvisioningSubject
return $this->hasOne(Subscription::class);
}
/** The module this order booked, if it was an add-on order. */
public function subscriptionAddon(): HasOne
{
return $this->hasOne(SubscriptionAddon::class);
}
/**
* A grant that costs nothing the one case IssueInvoice must skip.
*
* Keyed on the linked subscription/add-on's OWN provenance, not merely on
* `amount_cents === 0`: a fully discounted Stripe checkout also charges
* zero and that invoice is still owed (OpenSubscription's docblock says
* the same about the register). Only a grant with nothing charged, and
* chosen as a full gift rather than a discount, produces no invoice.
*/
public function isFreeGrant(): bool
{
if ((int) $this->amount_cents !== 0) {
return false;
}
return $this->subscription?->isFreeGrant()
?? $this->subscriptionAddon?->isGranted()
?? false;
}
public function runs(): MorphMany
{
return $this->morphMany(ProvisioningRun::class, 'subject');

View File

@ -63,6 +63,9 @@ class Subscription extends Model
'pending_effective_at' => 'datetime',
'cancelled_at' => 'datetime',
'stripe_event_at' => 'datetime',
'granted_at' => 'datetime',
'granted_until' => 'datetime',
'catalogue_price_cents' => 'integer',
];
}
@ -76,6 +79,27 @@ class Subscription extends Model
return $this->belongsTo(Customer::class);
}
/** The operator who gave this away, when it was a grant. */
public function grantedBy(): BelongsTo
{
return $this->belongsTo(Operator::class, 'granted_by');
}
/**
* Whether this contract's own terms were set by a grant rather than a
* purchase the one fact Revenue and IssueInvoice both key off.
*/
public function isGranted(): bool
{
return $this->granted_at !== null;
}
/** A full gift: nothing was charged for it at all. */
public function isFreeGrant(): bool
{
return $this->isGranted() && (int) $this->price_cents === 0;
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);

View File

@ -48,6 +48,9 @@ class SubscriptionAddon extends Model
'quantity' => 'integer',
'booked_at' => 'datetime',
'cancelled_at' => 'datetime',
'granted_at' => 'datetime',
'granted_until' => 'datetime',
'catalogue_price_cents' => 'integer',
];
}
@ -66,6 +69,17 @@ class SubscriptionAddon extends Model
return $this->belongsTo(Order::class);
}
/** The operator who gave this module away, when it was a grant. */
public function grantedBy(): BelongsTo
{
return $this->belongsTo(Operator::class, 'granted_by');
}
public function isGranted(): bool
{
return $this->granted_at !== null;
}
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('cancelled_at');

View File

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A grant is an order without money, not a second code path so a subscription
* given away for free or at a discount is a row in this same table, with
* `stripe_subscription_id` null and `price_cents` set to what the customer
* actually pays. What makes it a grant rather than a bug is provenance: who
* gave it, when, why (free text), and until when.
*
* `catalogue_price_cents` sits beside `price_cents` for exactly one reason: a
* discount is only legible as a discount if the price it was discounted FROM
* is on the same row. Left null for an ordinary paid subscription, where
* `price_cents` already IS the catalogue price at the time of sale.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
// Nullable and nullOnDelete: an operator leaving the company must
// not take the record of what they gave away with them — the grant
// itself outlives its own audit trail either way.
$table->foreignId('granted_by')->nullable()->after('cancelled_at')
->constrained('operators')->nullOnDelete();
// The presence of this column, not a separate boolean, is what
// marks a subscription as a grant — one fact, one column.
$table->timestamp('granted_at')->nullable()->after('granted_by');
$table->text('grant_note')->nullable()->after('granted_at');
// Null means unlimited. The owner's rule: nothing lapses on its
// own when this date passes — the console only has to say so.
$table->timestamp('granted_until')->nullable()->after('grant_note');
$table->unsignedInteger('catalogue_price_cents')->nullable()->after('granted_until');
});
}
public function down(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->dropConstrainedForeignId('granted_by');
$table->dropColumn(['granted_at', 'grant_note', 'granted_until', 'catalogue_price_cents']);
});
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The same provenance as subscriptions.granted_* (see that migration's
* docblock), for a module booked without a payment "ein Plugin schenken".
* A booked module already prices independently of the plan it sits on, so
* granting one has to be recorded independently too.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('subscription_addons', function (Blueprint $table) {
$table->foreignId('granted_by')->nullable()->after('cancelled_at')
->constrained('operators')->nullOnDelete();
$table->timestamp('granted_at')->nullable()->after('granted_by');
$table->text('grant_note')->nullable()->after('granted_at');
$table->timestamp('granted_until')->nullable()->after('grant_note');
$table->unsignedInteger('catalogue_price_cents')->nullable()->after('granted_until');
});
}
public function down(): void
{
Schema::table('subscription_addons', function (Blueprint $table) {
$table->dropConstrainedForeignId('granted_by');
$table->dropColumn(['granted_at', 'grant_note', 'granted_until', 'catalogue_price_cents']);
});
}
};

View File

@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Spatie\Permission\PermissionRegistrar;
/**
* Giving away a subscription or a module is its own capability, not folded
* into `customers.manage` (suspend/reactivate, impersonate reversible,
* low-stakes account administration). A grant creates real money-shaped rows
* an order, a contract, a running instance for nothing or at a discount,
* so the button needs its own gate rather than merely being hidden.
*
* Owner only, following secrets.manage's precedent: this is another page
* where the blast radius is someone else's money.
*/
return new class extends Migration
{
public function up(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
$exists = DB::table('permissions')->where('name', 'customers.grant_plan')->exists();
if (! $exists) {
DB::table('permissions')->insert([
'name' => 'customers.grant_plan', 'guard_name' => 'operator',
'created_at' => now(), 'updated_at' => now(),
]);
}
$permission = DB::table('permissions')->where('name', 'customers.grant_plan')->value('id');
$owner = DB::table('roles')->where('name', 'Owner')->where('guard_name', 'operator')->value('id');
if ($permission && $owner) {
DB::table('role_has_permissions')->updateOrInsert(
['permission_id' => $permission, 'role_id' => $owner],
);
}
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
public function down(): void
{
// Deliberately removes nothing — see add_secrets_capability.php for
// why: by the time down() runs there is no way to tell what this
// migration created from what it merely found already there.
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
};

View File

@ -12,7 +12,8 @@ use Spatie\Permission\Models\Role;
it('moves every permission and role to the operator guard, leaving none behind', function () {
expect(Permission::where('guard_name', 'web')->count())->toBe(0)
->and(Role::where('guard_name', 'web')->count())->toBe(0)
->and(Permission::where('guard_name', 'operator')->count())->toBe(17)
// 17 from the original seed, plus customers.grant_plan.
->and(Permission::where('guard_name', 'operator')->count())->toBe(18)
->and(Role::where('guard_name', 'operator')->count())->toBe(6);
});
@ -153,7 +154,7 @@ it('preflights every customer conflict before mutating anything, listing all of
// Nothing mutated: still exactly the pre-migration shape, not "moved and
// then rolled back" — there is nothing here to roll back on a real
// server, which is the whole reason this has to be checked up front.
expect(Permission::where('guard_name', 'web')->count())->toBe(17)
expect(Permission::where('guard_name', 'web')->count())->toBe(18)
->and(Permission::where('guard_name', 'operator')->count())->toBe(0)
->and(Role::where('guard_name', 'web')->count())->toBe(6)
->and(Role::where('guard_name', 'operator')->count())->toBe(0)

View File

@ -0,0 +1,71 @@
<?php
use App\Actions\GrantAddon;
use App\Actions\OpenSubscription;
use App\Models\Operator;
use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
/**
* "Ein Plugin schenken" a module given for free or at a discount, booked
* through the same BookAddon a paid order uses.
*/
function subscriptionForGrant(): Subscription
{
$order = Order::factory()->create(['plan' => 'team', 'datacenter' => 'fsn', 'status' => 'paid']);
return app(OpenSubscription::class)($order);
}
it('books a module at zero cost, with provenance on the row', function () {
$subscription = subscriptionForGrant();
$owner = Operator::factory()->create();
$addon = app(GrantAddon::class)(
subscription: $subscription,
grantedBy: $owner,
addonKey: 'priority_support',
priceCents: 0,
note: 'Entschuldigung für die Störung',
);
$catalogue = app(AddonCatalogue::class)->priceCents('priority_support');
expect($addon->price_cents)->toBe(0)
->and($addon->isGranted())->toBeTrue()
->and($addon->granted_by)->toBe($owner->id)
->and($addon->grant_note)->toBe('Entschuldigung für die Störung')
->and($addon->catalogue_price_cents)->toBe($catalogue)
->and($addon->order->stripe_event_id)->toBeNull()
->and($addon->order->amount_cents)->toBe(0);
});
it('books a discounted module below the catalogue price', function () {
$subscription = subscriptionForGrant();
$owner = Operator::factory()->create();
$catalogue = app(AddonCatalogue::class)->priceCents('collabora_pro');
$addon = app(GrantAddon::class)(
subscription: $subscription,
grantedBy: $owner,
addonKey: 'collabora_pro',
priceCents: $catalogue - 500,
);
expect($addon->price_cents)->toBe($catalogue - 500)
->and($addon->isGranted())->toBeTrue();
});
it('refuses to charge more for a granted module than it actually costs', function () {
$subscription = subscriptionForGrant();
$owner = Operator::factory()->create();
$catalogue = app(AddonCatalogue::class)->priceCents('extra_backups');
expect(fn () => app(GrantAddon::class)(
subscription: $subscription,
grantedBy: $owner,
addonKey: 'extra_backups',
priceCents: $catalogue + 1,
))->toThrow(RuntimeException::class);
});

View File

@ -0,0 +1,125 @@
<?php
use App\Actions\GrantSubscription;
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\Facades\Queue;
/**
* A grant is an order without money, not a second code path: it must produce
* the same rows order, provisioning run, subscription a paid purchase
* does, with provenance recorded and price_cents set to what the customer
* actually pays.
*/
it('opens a subscription with no money charged, and provenance recorded', function () {
Queue::fake();
$customer = Customer::factory()->create();
$owner = Operator::factory()->create();
$subscription = app(GrantSubscription::class)(
customer: $customer,
grantedBy: $owner,
plan: 'team',
term: Subscription::TERM_MONTHLY,
datacenter: 'fsn',
priceCents: 0,
note: 'Freund des Hauses',
);
$order = Order::query()->where('customer_id', $customer->id)->sole();
expect($order->amount_cents)->toBe(0)
->and($order->stripe_event_id)->toBeNull()
->and($subscription->price_cents)->toBe(0)
->and($subscription->plan)->toBe('team')
// The customer is owed the real machine, so the snapshot is the
// catalogue's — only the price and the provenance were overridden.
->and($subscription->ram_mb)->toBeGreaterThan(0)
->and($subscription->isGranted())->toBeTrue()
->and($subscription->isFreeGrant())->toBeTrue()
->and($subscription->granted_by)->toBe($owner->id)
->and($subscription->grant_note)->toBe('Freund des Hauses')
->and($subscription->granted_until)->toBeNull()
->and($subscription->catalogue_price_cents)->toBeGreaterThan(0);
// Same downstream pipeline as a paid order: a run was opened and dispatched.
$run = ProvisioningRun::query()->where('subject_id', $order->id)->sole();
expect($run->pipeline)->toBe('customer');
Queue::assertPushed(AdvanceRunJob::class, fn ($job) => $job->runUuid === $run->uuid);
});
it('records a discount as what it is: a price below the catalogue, not a free plan', function () {
$customer = Customer::factory()->create();
$owner = Operator::factory()->create();
$subscription = app(GrantSubscription::class)(
customer: $customer,
grantedBy: $owner,
plan: 'team',
term: Subscription::TERM_MONTHLY,
datacenter: 'fsn',
priceCents: 5000,
);
expect($subscription->price_cents)->toBe(5000)
->and($subscription->catalogue_price_cents)->toBeGreaterThan(5000)
->and($subscription->isFreeGrant())->toBeFalse()
->and($subscription->isGranted())->toBeTrue();
});
it('grants a quota bonus beyond the plan, baked into the frozen snapshot', function () {
$customer = Customer::factory()->create();
$owner = Operator::factory()->create();
$plainTeam = app(PlanCatalogue::class)->currentVersion('team');
$subscription = app(GrantSubscription::class)(
customer: $customer,
grantedBy: $owner,
plan: 'team',
term: Subscription::TERM_MONTHLY,
datacenter: 'fsn',
priceCents: 0,
bonus: ['quota_gb' => $plainTeam->quota_gb + 250],
);
expect($subscription->quota_gb)->toBe($plainTeam->quota_gb + 250);
});
it('refuses to charge more for a grant than the plan actually costs', function () {
$customer = Customer::factory()->create();
$owner = Operator::factory()->create();
$price = app(PlanCatalogue::class)->currentVersion('team')->priceFor('monthly')->amount_cents;
expect(fn () => app(GrantSubscription::class)(
customer: $customer,
grantedBy: $owner,
plan: 'team',
term: Subscription::TERM_MONTHLY,
datacenter: 'fsn',
priceCents: $price + 1,
))->toThrow(RuntimeException::class);
});
it('remembers until when a dated grant runs, and leaves it unlimited otherwise', function () {
$customer = Customer::factory()->create();
$owner = Operator::factory()->create();
$until = now()->addMonths(6);
$subscription = app(GrantSubscription::class)(
customer: $customer,
grantedBy: $owner,
plan: 'team',
term: Subscription::TERM_MONTHLY,
datacenter: 'fsn',
priceCents: 0,
until: $until,
);
expect($subscription->granted_until->toDateString())->toBe($until->toDateString());
});