diff --git a/app/Livewire/Admin/Customers.php b/app/Livewire/Admin/Customers.php index 106cd08..c6456d7 100644 --- a/app/Livewire/Admin/Customers.php +++ b/app/Livewire/Admin/Customers.php @@ -62,6 +62,10 @@ class Customers extends Component 'uuid' => $c->uuid, 'name' => $c->name, 'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—', + // A gift or a discount, not a sale — flagged here rather than + // inferred from the price, which a genuinely cheap plan could + // also show. + 'granted' => (bool) $contract?->isGranted(), 'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale), 'instance' => $instance->subdomain ?? '—', // Only an instance that exists can hand out an admin login. diff --git a/app/Livewire/Admin/GrantPlan.php b/app/Livewire/Admin/GrantPlan.php new file mode 100644 index 0000000..23e1d0c --- /dev/null +++ b/app/Livewire/Admin/GrantPlan.php @@ -0,0 +1,293 @@ +authorize('customers.grant_plan'); // modals are reachable without the route middleware + $customer = Customer::query()->where('uuid', $uuid)->firstOrFail(); + + $this->customerUuid = $uuid; + $this->customerName = (string) $customer->name; + $this->datacenter = (string) (Datacenter::query()->active()->orderBy('code')->value('code') ?? ''); + + $subscription = Subscription::query() + ->where('customer_id', $customer->id) + ->where('status', 'active') + ->latest('id') + ->first(); + + $this->hasSubscription = $subscription !== null; + $this->subscriptionId = $subscription?->id; + // A customer with nothing yet can only be given a package — there is + // no contract to hang a module off. + $this->kind = $this->hasSubscription ? 'addon' : 'package'; + } + + /** Today's price for whatever is picked, so a discount reads as one. */ + public function cataloguePriceCents(): ?int + { + try { + if ($this->kind === 'package' && $this->plan !== '') { + return app(PlanCatalogue::class)->currentVersion($this->plan)->priceFor($this->term)?->amount_cents; + } + + if ($this->kind === 'addon' && $this->addonKey !== '') { + return app(AddonCatalogue::class)->priceCents($this->addonKey); + } + } catch (Throwable) { + return null; + } + + return null; + } + + public function grant(): void + { + $this->authorize('customers.grant_plan'); + + $operator = auth('operator')->user(); + abort_if($operator === null, 403); + + $customer = Customer::query()->where('uuid', $this->customerUuid)->firstOrFail(); + + if ($this->kind === 'package') { + $this->grantPackage($customer, $operator); + } else { + $this->grantAddon($customer, $operator); + } + } + + private function grantPackage(Customer $customer, $operator): void + { + $priceRule = function (string $attribute, mixed $value, \Closure $fail): void { + if (Money::toCents((string) $value) === null) { + $fail(__('admin.grant.price_invalid')); + } + }; + + $data = $this->validate([ + 'plan' => ['required', Rule::in(array_keys(app(PlanCatalogue::class)->sellable()))], + 'term' => ['required', Rule::in([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY])], + 'datacenter' => ['required', Rule::exists('datacenters', 'code')->where('active', true)], + 'priceEuros' => ['required', 'string', $priceRule], + 'bonusQuotaGb' => 'nullable|integer|min:0|max:1000000', + 'bonusSeats' => 'nullable|integer|min:0|max:100000', + 'bonusTrafficGb' => 'nullable|integer|min:0|max:10000000', + 'note' => 'nullable|string|max:1000', + ]); + + try { + app(GrantSubscription::class)( + customer: $customer, + grantedBy: $operator, + plan: $data['plan'], + term: $data['term'], + datacenter: $data['datacenter'], + priceCents: Money::toCents($data['priceEuros']), + bonus: array_filter([ + 'quota_gb' => $data['bonusQuotaGb'] !== null && $data['bonusQuotaGb'] !== '' ? (int) $data['bonusQuotaGb'] : null, + 'seats' => $data['bonusSeats'] !== null && $data['bonusSeats'] !== '' ? (int) $data['bonusSeats'] : null, + 'traffic_gb' => $data['bonusTrafficGb'] !== null && $data['bonusTrafficGb'] !== '' ? (int) $data['bonusTrafficGb'] : null, + ], fn ($value) => $value !== null), + note: $data['note'] !== null && $data['note'] !== '' ? $data['note'] : null, + until: $this->parseUntil($this->until), + ); + } catch (RuntimeException $e) { + $this->addError('priceEuros', $e->getMessage()); + + return; + } + + $this->dispatch('notify', message: __('admin.grant.granted')); + $this->closeModal(); + } + + private function grantAddon(Customer $customer, $operator): void + { + $priceRule = function (string $attribute, mixed $value, \Closure $fail): void { + if (Money::toCents((string) $value) === null) { + $fail(__('admin.grant.price_invalid')); + } + }; + + $data = $this->validate([ + 'addonKey' => ['required', Rule::in(array_keys((array) config('provisioning.addons')))], + 'quantity' => 'required|integer|min:1|max:100', + 'priceEuros' => ['required', 'string', $priceRule], + 'note' => 'nullable|string|max:1000', + ]); + + // Re-resolved and re-checked, never trusted from the hydrated + // property: a forged request must not be able to book a module onto + // someone else's contract. + $subscription = Subscription::query() + ->where('customer_id', $customer->id) + ->whereKey($this->subscriptionId) + ->first(); + + if ($subscription === null) { + $this->addError('addonKey', __('admin.grant.no_subscription')); + + return; + } + + try { + app(GrantAddon::class)( + subscription: $subscription, + grantedBy: $operator, + addonKey: $data['addonKey'], + priceCents: Money::toCents($data['priceEuros']), + quantity: $data['quantity'], + note: $data['note'] !== null && $data['note'] !== '' ? $data['note'] : null, + until: $this->parseUntil($this->until), + ); + } catch (RuntimeException $e) { + $this->addError('priceEuros', $e->getMessage()); + + return; + } + + $this->dispatch('notify', message: __('admin.grant.granted')); + $this->closeModal(); + } + + /** + * A date, read in the operator's own zone — the same pairing LocalTime + * keeps for a datetime-local field, minus the time of day this form does + * not ask for. "Until 15 August" means through the end of that day where + * the operator is sitting, not UTC midnight. + */ + private function parseUntil(string $value): ?Carbon + { + if (trim($value) === '') { + return null; + } + + try { + return Carbon::parse($value, config('app.display_timezone'))->endOfDay()->utc(); + } catch (Throwable) { + return null; + } + } + + /** @return array> */ + private function existingGrants(int $customerId): array + { + $fromSubscriptions = Subscription::query() + ->where('customer_id', $customerId) + ->whereNotNull('granted_at') + ->with('grantedBy') + ->get() + ->map(fn (Subscription $s) => [ + 'kind' => 'package', + 'label' => __('billing.plan.'.$s->plan), + 'price_cents' => $s->price_cents, + 'catalogue_price_cents' => $s->catalogue_price_cents, + 'granted_by' => $s->grantedBy?->name ?? '—', + 'granted_at' => $s->granted_at, + 'granted_until' => $s->granted_until, + 'note' => $s->grant_note, + ]); + + $fromAddons = SubscriptionAddon::query() + ->whereHas('subscription', fn ($q) => $q->where('customer_id', $customerId)) + ->whereNotNull('granted_at') + ->with('grantedBy') + ->get() + ->map(fn (SubscriptionAddon $a) => [ + 'kind' => 'addon', + 'label' => __('billing.addon.'.$a->addon_key.'.name'), + 'price_cents' => $a->price_cents, + 'catalogue_price_cents' => $a->catalogue_price_cents, + 'granted_by' => $a->grantedBy?->name ?? '—', + 'granted_at' => $a->granted_at, + 'granted_until' => $a->granted_until, + 'note' => $a->grant_note, + ]); + + return $fromSubscriptions->concat($fromAddons) + ->sortByDesc('granted_at') + ->values() + ->all(); + } + + public function render() + { + // Read again rather than off the mounted properties: a second grant + // can have happened while this modal sat open (EditDatacenter's + // pattern), and the existing-grants list has to show it. + $customer = Customer::query()->where('uuid', $this->customerUuid)->firstOrFail(); + + return view('livewire.admin.grant-plan', [ + 'plans' => app(PlanCatalogue::class)->sellable(), + 'addons' => (array) config('provisioning.addons'), + 'datacenters' => Datacenter::query()->active()->orderBy('code')->pluck('name', 'code'), + 'cataloguePriceCents' => $this->cataloguePriceCents(), + 'grants' => $this->existingGrants($customer->id), + ]); + } +} diff --git a/lang/de/admin.php b/lang/de/admin.php index 7f2cab7..6e0a53a 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -91,6 +91,8 @@ return [ 'reactivate' => 'Entsperren', 'customer_suspended' => 'Kunde gesperrt.', 'customer_reactivated' => 'Kunde entsperrt.', + 'grant_action' => 'Schenken', + 'granted_badge' => 'Verschenkt', 'by_plan' => 'Nach Paket', 'instances_sub' => 'Alle bereitgestellten Cloud-Instanzen.', 'instances_label' => 'Instanzen', @@ -151,5 +153,43 @@ return [ 'arpu_sub' => 'MRR geteilt durch zahlende Kunden', 'contracts' => 'Laufende Verträge', 'contracts_sub' => 'Verträge mit Status aktiv', + 'granted' => 'Verschenkte Verträge', + 'granted_sub' => 'Geschenkt oder rabattiert — nicht in MRR/ARPU enthalten', + ], + + 'grant' => [ + 'title' => 'Paket oder Modul schenken', + 'existing_title' => 'Bestehende Vergaben', + 'kind_package' => 'Paket', + 'kind_addon' => 'Modul', + 'choose' => 'Bitte wählen', + 'plan' => 'Paket', + 'term' => 'Laufzeit', + 'monthly' => 'Monatlich', + 'yearly' => 'Jährlich', + 'datacenter' => 'Rechenzentrum', + 'bonus_title' => 'Zusätzliches Kontingent (optional)', + 'bonus_hint' => 'Über das hinaus, was das Paket bereits enthält. Leer lassen für keinen Bonus.', + 'bonus_quota' => 'Speicher (GB)', + 'bonus_seats' => 'Sitze', + 'bonus_traffic' => 'Traffic (GB)', + 'addon' => 'Modul', + 'quantity' => 'Menge', + 'price' => 'Preis, den der Kunde zahlt', + 'catalogue_price' => 'Regulärer Preis: :price', + 'until_label' => 'Befristet bis', + 'until_hint' => 'Leer lassen für unbefristet. Nichts läuft automatisch ab — die Konsole weist rechtzeitig darauf hin.', + 'note' => 'Notiz (intern, z. B. Anlass der Vergabe)', + 'free' => 'Kostenlos', + 'by_on' => 'Vergeben von :who am :when', + 'until' => 'Befristet bis :date', + 'unlimited' => 'Unbefristet', + 'ending_soon' => 'läuft bald ab', + 'lapsed' => 'Frist überschritten', + 'cancel' => 'Abbrechen', + 'submit' => 'Schenken', + 'granted' => 'Vergabe gespeichert.', + 'price_invalid' => 'Bitte einen gültigen Betrag angeben.', + 'no_subscription' => 'Für diesen Kunden ist kein aktiver Vertrag vorhanden.', ], ]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 5f9fb24..b3c3340 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -91,6 +91,8 @@ return [ 'reactivate' => 'Reactivate', 'customer_suspended' => 'Customer suspended.', 'customer_reactivated' => 'Customer reactivated.', + 'grant_action' => 'Grant', + 'granted_badge' => 'Granted', 'by_plan' => 'By plan', 'instances_sub' => 'All provisioned cloud instances.', 'instances_label' => 'instances', @@ -151,5 +153,43 @@ return [ 'arpu_sub' => 'MRR divided by paying customers', 'contracts' => 'Live contracts', 'contracts_sub' => 'Contracts with status active', + 'granted' => 'Granted contracts', + 'granted_sub' => 'Gifted or discounted — not counted in MRR/ARPU', + ], + + 'grant' => [ + 'title' => 'Grant a package or module', + 'existing_title' => 'Existing grants', + 'kind_package' => 'Package', + 'kind_addon' => 'Module', + 'choose' => 'Please choose', + 'plan' => 'Package', + 'term' => 'Term', + 'monthly' => 'Monthly', + 'yearly' => 'Yearly', + 'datacenter' => 'Datacenter', + 'bonus_title' => 'Extra quota (optional)', + 'bonus_hint' => 'Beyond what the package already includes. Leave blank for no bonus.', + 'bonus_quota' => 'Storage (GB)', + 'bonus_seats' => 'Seats', + 'bonus_traffic' => 'Traffic (GB)', + 'addon' => 'Module', + 'quantity' => 'Quantity', + 'price' => 'Price the customer pays', + 'catalogue_price' => 'Regular price: :price', + 'until_label' => 'Limited until', + 'until_hint' => 'Leave blank for unlimited. Nothing lapses automatically — the console flags it in good time.', + 'note' => 'Note (internal, e.g. the reason for the grant)', + 'free' => 'Free', + 'by_on' => 'Granted by :who on :when', + 'until' => 'Limited until :date', + 'unlimited' => 'Unlimited', + 'ending_soon' => 'ending soon', + 'lapsed' => 'past due', + 'cancel' => 'Cancel', + 'submit' => 'Grant', + 'granted' => 'Grant saved.', + 'price_invalid' => 'Please enter a valid amount.', + 'no_subscription' => 'This customer has no active contract.', ], ]; diff --git a/resources/views/livewire/admin/customers.blade.php b/resources/views/livewire/admin/customers.blade.php index 4411603..d23dfcc 100644 --- a/resources/views/livewire/admin/customers.blade.php +++ b/resources/views/livewire/admin/customers.blade.php @@ -24,7 +24,15 @@

{{ $r['name'] }}

{{ $r['instance'] }}.clupilot.com

- {{ $r['plan'] }} + + {{ $r['plan'] }} + @if ($r['granted']) + + + {{ __('admin.granted_badge') }} + + @endif + {{ $r['mrr'] }} {{ __('admin.status.'.$r['status']) }} @@ -36,6 +44,14 @@ {{ $r['suspended'] ? __('admin.reactivate') : __('admin.suspend') }} @endunless + @if (auth()->user()?->can('customers.grant_plan')) + + @endif {{-- Impersonation borrows the customer's PORTAL session; this is administrator access to their Nextcloud itself. Two different things, so two buttons. --}} diff --git a/resources/views/livewire/admin/grant-plan.blade.php b/resources/views/livewire/admin/grant-plan.blade.php new file mode 100644 index 0000000..2ac0bd0 --- /dev/null +++ b/resources/views/livewire/admin/grant-plan.blade.php @@ -0,0 +1,182 @@ + +
+
+

{{ __('admin.grant.title') }}

+ {{ $customerName }} +
+ + @if (count($grants) > 0) +
+

{{ __('admin.grant.existing_title') }}

+
    + @foreach ($grants as $g) + @php + $lapsed = $g['granted_until'] && $g['granted_until']->isPast(); + $nearingEnd = ! $lapsed && $g['granted_until'] && now()->diffInDays($g['granted_until'], false) <= 30; + @endphp +
  • +
    + + + {{ $g['label'] }} + + + {{ $g['price_cents'] === 0 ? __('admin.grant.free') : Number::currency($g['price_cents'] / 100, in: 'EUR', locale: app()->getLocale()) }} + +
    +

    + {{ __('admin.grant.by_on', ['who' => $g['granted_by'], 'when' => $g['granted_at']->local()->isoFormat('D. MMM YYYY')]) }} +

    + @if ($g['note']) +

    {{ $g['note'] }}

    + @endif + @if ($g['granted_until']) +

    $lapsed, + 'bg-warning-bg text-warning' => $nearingEnd, + 'bg-surface-2 text-muted' => ! $lapsed && ! $nearingEnd, + ])> + + {{ __('admin.grant.until', ['date' => $g['granted_until']->local()->isoFormat('D. MMM YYYY')]) }} + @if ($lapsed) + · {{ __('admin.grant.lapsed') }} + @elseif ($nearingEnd) + · {{ __('admin.grant.ending_soon') }} + @endif +

    + @else +

    {{ __('admin.grant.unlimited') }}

    + @endif +
  • + @endforeach +
+
+ @endif + +
+ @if ($hasSubscription) +
+ + +
+ @endif + + @if ($kind === 'package') +
+
+ + + @error('plan')

{{ $message }}

@enderror +
+
+
+ + +
+
+ + + @error('datacenter')

{{ $message }}

@enderror +
+
+ +
+

{{ __('admin.grant.bonus_title') }}

+

{{ __('admin.grant.bonus_hint') }}

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ @else +
+
+ + + @error('addonKey')

{{ $message }}

@enderror +
+
+ + + @error('quantity')

{{ $message }}

@enderror +
+
+ @endif + +
+
+ +
+ + +
+ @if ($cataloguePriceCents !== null) +

+ {{ __('admin.grant.catalogue_price', ['price' => Number::currency($cataloguePriceCents / 100, in: 'EUR', locale: app()->getLocale())]) }} +

+ @endif + @error('priceEuros')

{{ $message }}

@enderror +
+
+ + +

{{ __('admin.grant.until_hint') }}

+
+
+ +
+ + + @error('note')

{{ $message }}

@enderror +
+
+ +
+ {{ __('admin.grant.cancel') }} + + {{ __('admin.grant.submit') }} + +
+
diff --git a/tests/Feature/Admin/GrantPlanTest.php b/tests/Feature/Admin/GrantPlanTest.php new file mode 100644 index 0000000..a34ffa5 --- /dev/null +++ b/tests/Feature/Admin/GrantPlanTest.php @@ -0,0 +1,109 @@ +create(); + + // Support has customers.manage (can suspend, impersonate) but never had + // customers.grant_plan — giving away service is a bigger deal than a + // reversible suspend, and the seed migration deliberately keeps this one + // to Owner. A capability check that only hid the button would still let + // this request through. + Livewire::actingAs(operator('Support'), 'operator') + ->test(GrantPlan::class, ['uuid' => $customer->uuid]) + ->assertForbidden(); +}); + +it('hides the grant button from an operator without the capability', function () { + Customer::factory()->create(); + + Livewire::actingAs(operator('Support'), 'operator') + ->test(Customers::class) + ->assertDontSee(__('admin.grant_action')); +}); + +it('shows the grant button to the Owner', function () { + Customer::factory()->create(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Customers::class) + ->assertSee(__('admin.grant_action')); +}); + +it('grants a free package to a customer with no contract yet', function () { + Queue::fake(); + $customer = Customer::factory()->create(); + $owner = operator('Owner'); + + Livewire::actingAs($owner, 'operator') + ->test(GrantPlan::class, ['uuid' => $customer->uuid]) + ->set('plan', 'team') + ->set('term', 'monthly') + ->set('datacenter', 'fsn') + ->set('priceEuros', '0') + ->set('note', 'Testkunde') + ->call('grant') + ->assertHasNoErrors(); + + $subscription = Subscription::query()->where('customer_id', $customer->id)->sole(); + + expect($subscription->price_cents)->toBe(0) + ->and($subscription->isGranted())->toBeTrue() + ->and($subscription->granted_by)->toBe($owner->id); + + Queue::assertPushed(AdvanceRunJob::class); +}); + +it('grants a discounted module onto an existing contract', function () { + $customer = Customer::factory()->create(); + Subscription::factory()->create(['customer_id' => $customer->id, 'status' => 'active']); + $owner = operator('Owner'); + + Livewire::actingAs($owner, 'operator') + ->test(GrantPlan::class, ['uuid' => $customer->uuid]) + ->assertSet('kind', 'addon') // a contract already exists — default to the module form + ->set('addonKey', 'priority_support') + ->set('quantity', 1) + ->set('priceEuros', '5,00') + ->call('grant') + ->assertHasNoErrors(); + + $addon = SubscriptionAddon::query()->where('addon_key', 'priority_support')->sole(); + + expect($addon->price_cents)->toBe(500) + ->and($addon->isGranted())->toBeTrue() + ->and($addon->granted_by)->toBe($owner->id); +}); + +it('shows who granted what, when, and until when', function () { + $customer = Customer::factory()->create(); + $owner = operator('Owner'); + $until = now()->addMonths(3); + + Subscription::factory()->create([ + 'customer_id' => $customer->id, 'status' => 'active', + 'price_cents' => 0, 'granted_by' => $owner->id, 'granted_at' => now(), + 'grant_note' => 'Sponsoring', 'granted_until' => $until, 'catalogue_price_cents' => 17900, + ]); + + Livewire::actingAs($owner, 'operator') + ->test(GrantPlan::class, ['uuid' => $customer->uuid]) + ->assertSee($owner->name) + ->assertSee('Sponsoring') + ->assertSee($until->local()->isoFormat('D. MMM YYYY')); +});