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
|
* 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
|
* have NOT booked stays on the live catalogue — that is a sale still to be
|
||||||
* made, at whatever it costs now.
|
* 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
|
class BookAddon
|
||||||
{
|
{
|
||||||
public function __construct(private RecordCommercialEvent $record) {}
|
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);
|
$price = app(AddonCatalogue::class)->priceCents($addonKey);
|
||||||
|
|
||||||
|
|
@ -36,7 +41,7 @@ class BookAddon
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return $this->book($subscription, $addonKey, $quantity, $order, $price);
|
return $this->book($subscription, $addonKey, $quantity, $order, $price, $overrides);
|
||||||
} catch (UniqueConstraintViolationException) {
|
} catch (UniqueConstraintViolationException) {
|
||||||
// A concurrent retry won. The unique index on (order_id, addon_key)
|
// A concurrent retry won. The unique index on (order_id, addon_key)
|
||||||
// is what actually enforces "one order books one module" — the
|
// 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
|
// The booking and its entry in the register commit together: if the
|
||||||
// record could fail afterwards, retrying the same order would find the
|
// record could fail afterwards, retrying the same order would find the
|
||||||
// add-on already there and skip the event for good.
|
// 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.
|
// Idempotent against a retried webhook: one order books one module.
|
||||||
if ($order !== null) {
|
if ($order !== null) {
|
||||||
$existing = SubscriptionAddon::query()
|
$existing = SubscriptionAddon::query()
|
||||||
|
|
@ -67,7 +73,7 @@ class BookAddon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$addon = SubscriptionAddon::create([
|
$addon = SubscriptionAddon::create(array_merge([
|
||||||
'subscription_id' => $subscription->id,
|
'subscription_id' => $subscription->id,
|
||||||
'order_id' => $order?->id,
|
'order_id' => $order?->id,
|
||||||
'addon_key' => $addonKey,
|
'addon_key' => $addonKey,
|
||||||
|
|
@ -75,13 +81,13 @@ class BookAddon
|
||||||
'currency' => Subscription::catalogueCurrency(),
|
'currency' => Subscription::catalogueCurrency(),
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'booked_at' => now(),
|
'booked_at' => now(),
|
||||||
]);
|
], $overrides));
|
||||||
|
|
||||||
($this->record)(
|
($this->record)(
|
||||||
event: SubscriptionRecord::EVENT_ADDON_BOOKED,
|
event: SubscriptionRecord::EVENT_ADDON_BOOKED,
|
||||||
subscription: $subscription,
|
subscription: $subscription,
|
||||||
netCents: $addon->monthlyCents(),
|
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,
|
order: $order,
|
||||||
stripe: ['event' => $order?->stripe_event_id],
|
stripe: ['event' => $order?->stripe_event_id],
|
||||||
// What was actually charged for the module, on the same terms
|
// 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
|
* 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
|
* the amount. Copying a gross total into this net field would silently corrupt
|
||||||
* every pro-rata calculation that reads it.
|
* 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
|
class OpenSubscription
|
||||||
{
|
{
|
||||||
public function __construct(private RecordCommercialEvent $record) {}
|
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.
|
// A retried webhook must not open a second contract for one purchase.
|
||||||
$existing = Subscription::query()->where('order_id', $order->id)->first();
|
$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
|
// record could fail after the subscription is in, the next webhook
|
||||||
// retry would find the contract, return early, and leave a paid sale
|
// retry would find the contract, return early, and leave a paid sale
|
||||||
// permanently missing from the evidence.
|
// 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(
|
$subscription = Subscription::create(array_merge(
|
||||||
// The version the order carries, when the checkout recorded one:
|
// The version the order carries, when the checkout recorded one:
|
||||||
|
|
@ -67,6 +76,9 @@ class OpenSubscription
|
||||||
: $start->copy()->addMonth(),
|
: $start->copy()->addMonth(),
|
||||||
'status' => 'active',
|
'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
|
// The sale, entered in the register the moment it happens. Written from
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\Concerns\HasUuid;
|
use App\Models\Concerns\HasUuid;
|
||||||
use App\Services\Billing\TaxTreatment;
|
|
||||||
use App\Provisioning\Contracts\ProvisioningSubject;
|
use App\Provisioning\Contracts\ProvisioningSubject;
|
||||||
|
use App\Services\Billing\TaxTreatment;
|
||||||
|
use Database\Factories\OrderFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
@ -13,7 +14,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
|
|
||||||
class Order extends Model implements ProvisioningSubject
|
class Order extends Model implements ProvisioningSubject
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\OrderFactory> */
|
/** @use HasFactory<OrderFactory> */
|
||||||
use HasFactory, HasUuid;
|
use HasFactory, HasUuid;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|
@ -91,6 +92,32 @@ class Order extends Model implements ProvisioningSubject
|
||||||
return $this->hasOne(Subscription::class);
|
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
|
public function runs(): MorphMany
|
||||||
{
|
{
|
||||||
return $this->morphMany(ProvisioningRun::class, 'subject');
|
return $this->morphMany(ProvisioningRun::class, 'subject');
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,9 @@ class Subscription extends Model
|
||||||
'pending_effective_at' => 'datetime',
|
'pending_effective_at' => 'datetime',
|
||||||
'cancelled_at' => 'datetime',
|
'cancelled_at' => 'datetime',
|
||||||
'stripe_event_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);
|
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
|
public function order(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Order::class);
|
return $this->belongsTo(Order::class);
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,9 @@ class SubscriptionAddon extends Model
|
||||||
'quantity' => 'integer',
|
'quantity' => 'integer',
|
||||||
'booked_at' => 'datetime',
|
'booked_at' => 'datetime',
|
||||||
'cancelled_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);
|
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
|
public function scopeActive(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->whereNull('cancelled_at');
|
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 () {
|
it('moves every permission and role to the operator guard, leaving none behind', function () {
|
||||||
expect(Permission::where('guard_name', 'web')->count())->toBe(0)
|
expect(Permission::where('guard_name', 'web')->count())->toBe(0)
|
||||||
->and(Role::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);
|
->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
|
// Nothing mutated: still exactly the pre-migration shape, not "moved and
|
||||||
// then rolled back" — there is nothing here to roll back on a real
|
// 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.
|
// 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(Permission::where('guard_name', 'operator')->count())->toBe(0)
|
||||||
->and(Role::where('guard_name', 'web')->count())->toBe(6)
|
->and(Role::where('guard_name', 'web')->count())->toBe(6)
|
||||||
->and(Role::where('guard_name', 'operator')->count())->toBe(0)
|
->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