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
parent
696c8834b8
commit
602602864a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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());
|
||||
});
|
||||
Loading…
Reference in New Issue