diff --git a/app/Actions/BookAddon.php b/app/Actions/BookAddon.php index a69f0cb..6e86b57 100644 --- a/app/Actions/BookAddon.php +++ b/app/Actions/BookAddon.php @@ -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 $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 $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 diff --git a/app/Actions/GrantAddon.php b/app/Actions/GrantAddon.php new file mode 100644 index 0000000..7af8826 --- /dev/null +++ b/app/Actions/GrantAddon.php @@ -0,0 +1,70 @@ +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, + ]); + } +} diff --git a/app/Actions/GrantSubscription.php b/app/Actions/GrantSubscription.php new file mode 100644 index 0000000..9aa110e --- /dev/null +++ b/app/Actions/GrantSubscription.php @@ -0,0 +1,116 @@ +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; + } +} diff --git a/app/Actions/OpenSubscription.php b/app/Actions/OpenSubscription.php index 496ac4a..58ada8c 100644 --- a/app/Actions/OpenSubscription.php +++ b/app/Actions/OpenSubscription.php @@ -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 $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 $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 diff --git a/app/Models/Order.php b/app/Models/Order.php index e507d15..a14bd82 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -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 */ 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'); diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 82d927a..56611d5 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -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); diff --git a/app/Models/SubscriptionAddon.php b/app/Models/SubscriptionAddon.php index 29cc814..fa8f0f6 100644 --- a/app/Models/SubscriptionAddon.php +++ b/app/Models/SubscriptionAddon.php @@ -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'); diff --git a/database/migrations/2026_07_29_180000_add_grant_provenance_to_subscriptions.php b/database/migrations/2026_07_29_180000_add_grant_provenance_to_subscriptions.php new file mode 100644 index 0000000..70c7eb1 --- /dev/null +++ b/database/migrations/2026_07_29_180000_add_grant_provenance_to_subscriptions.php @@ -0,0 +1,47 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_07_29_180001_add_grant_provenance_to_subscription_addons.php b/database/migrations/2026_07_29_180001_add_grant_provenance_to_subscription_addons.php new file mode 100644 index 0000000..6b3f8c6 --- /dev/null +++ b/database/migrations/2026_07_29_180001_add_grant_provenance_to_subscription_addons.php @@ -0,0 +1,34 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_07_29_180002_add_grant_plans_capability.php b/database/migrations/2026_07_29_180002_add_grant_plans_capability.php new file mode 100644 index 0000000..0517ea2 --- /dev/null +++ b/database/migrations/2026_07_29_180002_add_grant_plans_capability.php @@ -0,0 +1,51 @@ +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(); + } +}; diff --git a/tests/Feature/Admin/RbacMoveTest.php b/tests/Feature/Admin/RbacMoveTest.php index de1cc8a..5b8d756 100644 --- a/tests/Feature/Admin/RbacMoveTest.php +++ b/tests/Feature/Admin/RbacMoveTest.php @@ -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) diff --git a/tests/Feature/Billing/GrantAddonTest.php b/tests/Feature/Billing/GrantAddonTest.php new file mode 100644 index 0000000..2c6fe42 --- /dev/null +++ b/tests/Feature/Billing/GrantAddonTest.php @@ -0,0 +1,71 @@ +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); +}); diff --git a/tests/Feature/Billing/GrantSubscriptionTest.php b/tests/Feature/Billing/GrantSubscriptionTest.php new file mode 100644 index 0000000..112df27 --- /dev/null +++ b/tests/Feature/Billing/GrantSubscriptionTest.php @@ -0,0 +1,125 @@ +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()); +});