Grant a package or module from the customer's row in the console
tests / pest (push) Failing after 7m55s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details

GrantPlan is a modal (R20), opened from a "Schenken" button on the customer's
own row in Admin\Customers, gated by customers.grant_plan rather than merely
hidden. It offers a whole package (plan, term, datacenter, an optional quota
bonus baked into the frozen snapshot) or a module on an existing contract,
either free or at a discount the operator sets against today's catalogue
price. Existing grants show on the same modal — who gave what, when, and
until when — with a warning once a dated grant is nearing its end; nothing
lapses on its own, exactly as decided.

The customers list also badges a granted plan in the Paket column.
feat/granted-plans
nexxo 2026-07-29 12:41:47 +02:00
parent c71b2c362b
commit 3b85e8f1d1
7 changed files with 685 additions and 1 deletions

View File

@ -62,6 +62,10 @@ class Customers extends Component
'uuid' => $c->uuid, 'uuid' => $c->uuid,
'name' => $c->name, 'name' => $c->name,
'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—', '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), 'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale),
'instance' => $instance->subdomain ?? '—', 'instance' => $instance->subdomain ?? '—',
// Only an instance that exists can hand out an admin login. // Only an instance that exists can hand out an admin login.

View File

@ -0,0 +1,293 @@
<?php
namespace App\Livewire\Admin;
use App\Actions\GrantAddon;
use App\Actions\GrantSubscription;
use App\Models\Customer;
use App\Models\Datacenter;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\PlanCatalogue;
use App\Support\Money;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use LivewireUI\Modal\ModalComponent;
use RuntimeException;
use Throwable;
/**
* Gives a customer a package, a module, or a discount without a payment.
*
* Reuses GrantSubscription / GrantAddon for the actual mutation: this
* component is only the form. What it grants goes through the exact Order
* Subscription/SubscriptionAddon rows a Stripe purchase would, so everything
* downstream (provisioning, quotas, cancellation, revenue) reads it the same
* way as a sale.
*/
class GrantPlan extends ModalComponent
{
public string $customerUuid = '';
public string $customerName = '';
/** Which form is on screen: a whole package, or a single module. */
public string $kind = 'package';
public bool $hasSubscription = false;
// Package fields.
public string $plan = '';
public string $term = Subscription::TERM_MONTHLY;
public string $datacenter = '';
public string $bonusQuotaGb = '';
public string $bonusSeats = '';
public string $bonusTrafficGb = '';
// Add-on fields.
public ?int $subscriptionId = null;
public string $addonKey = '';
public int $quantity = 1;
// Shared.
public string $priceEuros = '0';
public string $note = '';
/** A plain date — "until when", not "until what time". */
public string $until = '';
public function mount(string $uuid): void
{
$this->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<int, array<string, mixed>> */
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),
]);
}
}

View File

@ -91,6 +91,8 @@ return [
'reactivate' => 'Entsperren', 'reactivate' => 'Entsperren',
'customer_suspended' => 'Kunde gesperrt.', 'customer_suspended' => 'Kunde gesperrt.',
'customer_reactivated' => 'Kunde entsperrt.', 'customer_reactivated' => 'Kunde entsperrt.',
'grant_action' => 'Schenken',
'granted_badge' => 'Verschenkt',
'by_plan' => 'Nach Paket', 'by_plan' => 'Nach Paket',
'instances_sub' => 'Alle bereitgestellten Cloud-Instanzen.', 'instances_sub' => 'Alle bereitgestellten Cloud-Instanzen.',
'instances_label' => 'Instanzen', 'instances_label' => 'Instanzen',
@ -151,5 +153,43 @@ return [
'arpu_sub' => 'MRR geteilt durch zahlende Kunden', 'arpu_sub' => 'MRR geteilt durch zahlende Kunden',
'contracts' => 'Laufende Verträge', 'contracts' => 'Laufende Verträge',
'contracts_sub' => 'Verträge mit Status aktiv', '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.',
], ],
]; ];

View File

@ -91,6 +91,8 @@ return [
'reactivate' => 'Reactivate', 'reactivate' => 'Reactivate',
'customer_suspended' => 'Customer suspended.', 'customer_suspended' => 'Customer suspended.',
'customer_reactivated' => 'Customer reactivated.', 'customer_reactivated' => 'Customer reactivated.',
'grant_action' => 'Grant',
'granted_badge' => 'Granted',
'by_plan' => 'By plan', 'by_plan' => 'By plan',
'instances_sub' => 'All provisioned cloud instances.', 'instances_sub' => 'All provisioned cloud instances.',
'instances_label' => 'instances', 'instances_label' => 'instances',
@ -151,5 +153,43 @@ return [
'arpu_sub' => 'MRR divided by paying customers', 'arpu_sub' => 'MRR divided by paying customers',
'contracts' => 'Live contracts', 'contracts' => 'Live contracts',
'contracts_sub' => 'Contracts with status active', '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.',
], ],
]; ];

View File

@ -24,7 +24,15 @@
<p class="font-medium text-ink">{{ $r['name'] }}</p> <p class="font-medium text-ink">{{ $r['name'] }}</p>
<p class="font-mono text-xs text-muted">{{ $r['instance'] }}.clupilot.com</p> <p class="font-mono text-xs text-muted">{{ $r['instance'] }}.clupilot.com</p>
</td> </td>
<td class="px-4 py-3 text-body">{{ $r['plan'] }}</td> <td class="px-4 py-3 text-body">
{{ $r['plan'] }}
@if ($r['granted'])
<span class="ml-1.5 inline-flex items-center gap-1 rounded-pill bg-accent-bg px-1.5 py-0.5 text-[11px] font-medium text-accent-text">
<x-ui.icon name="tag" class="size-3" />
{{ __('admin.granted_badge') }}
</span>
@endif
</td>
<td class="px-4 py-3 font-mono text-body">{{ $r['mrr'] }}</td> <td class="px-4 py-3 font-mono text-body">{{ $r['mrr'] }}</td>
<td class="px-4 py-3"><x-ui.badge :status="$r['status']">{{ __('admin.status.'.$r['status']) }}</x-ui.badge></td> <td class="px-4 py-3"><x-ui.badge :status="$r['status']">{{ __('admin.status.'.$r['status']) }}</x-ui.badge></td>
<td class="px-4 py-3"> <td class="px-4 py-3">
@ -36,6 +44,14 @@
{{ $r['suspended'] ? __('admin.reactivate') : __('admin.suspend') }} {{ $r['suspended'] ? __('admin.reactivate') : __('admin.suspend') }}
</button> </button>
@endunless @endunless
@if (auth()->user()?->can('customers.grant_plan'))
<button type="button" title="{{ __('admin.grant_action') }}"
x-on:click="$dispatch('openModal', { component: 'admin.grant-plan', arguments: { uuid: '{{ $r['uuid'] }}' } })"
class="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-xs font-semibold text-body hover:border-accent-border hover:text-accent-text">
<x-ui.icon name="tag" class="size-3.5" />
{{ __('admin.grant_action') }}
</button>
@endif
{{-- Impersonation borrows the customer's PORTAL session; this {{-- Impersonation borrows the customer's PORTAL session; this
is administrator access to their Nextcloud itself. Two is administrator access to their Nextcloud itself. Two
different things, so two buttons. --}} different things, so two buttons. --}}

View File

@ -0,0 +1,182 @@
<?php use Illuminate\Support\Number; ?>
<div class="rounded-lg bg-surface p-6">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold text-ink">{{ __('admin.grant.title') }}</h3>
<span class="rounded bg-surface-2 px-2 py-0.5 text-xs text-muted">{{ $customerName }}</span>
</div>
@if (count($grants) > 0)
<div class="mt-4 rounded-lg border border-line bg-surface-2 p-3">
<p class="text-xs font-semibold uppercase tracking-wider text-muted">{{ __('admin.grant.existing_title') }}</p>
<ul class="mt-2 space-y-2">
@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
<li class="rounded-md bg-surface px-3 py-2 text-sm">
<div class="flex items-center justify-between gap-2">
<span class="inline-flex items-center gap-1.5 font-medium text-ink">
<x-ui.icon :name="$g['kind'] === 'package' ? 'box' : 'plug'" class="size-3.5 text-muted" />
{{ $g['label'] }}
</span>
<span class="font-mono text-xs text-muted">
{{ $g['price_cents'] === 0 ? __('admin.grant.free') : Number::currency($g['price_cents'] / 100, in: 'EUR', locale: app()->getLocale()) }}
</span>
</div>
<p class="mt-0.5 text-xs text-muted">
{{ __('admin.grant.by_on', ['who' => $g['granted_by'], 'when' => $g['granted_at']->local()->isoFormat('D. MMM YYYY')]) }}
</p>
@if ($g['note'])
<p class="mt-0.5 text-xs italic text-muted">{{ $g['note'] }}</p>
@endif
@if ($g['granted_until'])
<p @class([
'mt-1.5 inline-flex items-center gap-1 rounded-pill px-2 py-0.5 text-xs font-medium',
'bg-danger-bg text-danger' => $lapsed,
'bg-warning-bg text-warning' => $nearingEnd,
'bg-surface-2 text-muted' => ! $lapsed && ! $nearingEnd,
])>
<x-ui.icon name="calendar" class="size-3.5" />
{{ __('admin.grant.until', ['date' => $g['granted_until']->local()->isoFormat('D. MMM YYYY')]) }}
@if ($lapsed)
· {{ __('admin.grant.lapsed') }}
@elseif ($nearingEnd)
· {{ __('admin.grant.ending_soon') }}
@endif
</p>
@else
<p class="mt-1.5 text-xs text-muted">{{ __('admin.grant.unlimited') }}</p>
@endif
</li>
@endforeach
</ul>
</div>
@endif
<div class="mt-5">
@if ($hasSubscription)
<div class="flex rounded-md border border-line-strong p-1 text-sm">
<button type="button" wire:click="$set('kind', 'package')"
class="flex-1 rounded px-3 py-1.5 font-medium {{ $kind === 'package' ? 'bg-accent text-white' : 'text-muted' }}">
{{ __('admin.grant.kind_package') }}
</button>
<button type="button" wire:click="$set('kind', 'addon')"
class="flex-1 rounded px-3 py-1.5 font-medium {{ $kind === 'addon' ? 'bg-accent text-white' : 'text-muted' }}">
{{ __('admin.grant.kind_addon') }}
</button>
</div>
@endif
@if ($kind === 'package')
<div class="mt-4 space-y-4">
<div>
<label class="text-sm font-medium text-body" for="grant-plan">{{ __('admin.grant.plan') }}</label>
<select id="grant-plan" wire:model.live="plan" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="">{{ __('admin.grant.choose') }}</option>
@foreach ($plans as $key => $p)
<option value="{{ $key }}">{{ $p['name'] }}</option>
@endforeach
</select>
@error('plan')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-sm font-medium text-body" for="grant-term">{{ __('admin.grant.term') }}</label>
<select id="grant-term" wire:model.live="term" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="monthly">{{ __('admin.grant.monthly') }}</option>
<option value="yearly">{{ __('admin.grant.yearly') }}</option>
</select>
</div>
<div>
<label class="text-sm font-medium text-body" for="grant-datacenter">{{ __('admin.grant.datacenter') }}</label>
<select id="grant-datacenter" wire:model="datacenter" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
@foreach ($datacenters as $code => $name)
<option value="{{ $code }}">{{ $name }} ({{ $code }})</option>
@endforeach
</select>
@error('datacenter')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
</div>
<div class="rounded-lg border border-line bg-surface-2 p-3">
<p class="text-xs font-semibold uppercase tracking-wider text-muted">{{ __('admin.grant.bonus_title') }}</p>
<p class="mt-1 text-xs text-muted">{{ __('admin.grant.bonus_hint') }}</p>
<div class="mt-2 grid grid-cols-3 gap-3">
<div>
<label class="text-xs text-muted" for="grant-bonus-quota">{{ __('admin.grant.bonus_quota') }}</label>
<input id="grant-bonus-quota" type="number" min="0" wire:model="bonusQuotaGb" placeholder="0"
class="mt-1 w-full rounded-md border border-line-strong bg-surface px-2 py-1.5 text-sm text-ink" />
</div>
<div>
<label class="text-xs text-muted" for="grant-bonus-seats">{{ __('admin.grant.bonus_seats') }}</label>
<input id="grant-bonus-seats" type="number" min="0" wire:model="bonusSeats" placeholder="0"
class="mt-1 w-full rounded-md border border-line-strong bg-surface px-2 py-1.5 text-sm text-ink" />
</div>
<div>
<label class="text-xs text-muted" for="grant-bonus-traffic">{{ __('admin.grant.bonus_traffic') }}</label>
<input id="grant-bonus-traffic" type="number" min="0" wire:model="bonusTrafficGb" placeholder="0"
class="mt-1 w-full rounded-md border border-line-strong bg-surface px-2 py-1.5 text-sm text-ink" />
</div>
</div>
</div>
</div>
@else
<div class="mt-4 space-y-4">
<div>
<label class="text-sm font-medium text-body" for="grant-addon">{{ __('admin.grant.addon') }}</label>
<select id="grant-addon" wire:model.live="addonKey" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="">{{ __('admin.grant.choose') }}</option>
@foreach (array_keys($addons) as $key)
<option value="{{ $key }}">{{ __('billing.addon.'.$key.'.name') }}</option>
@endforeach
</select>
@error('addonKey')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="grant-quantity">{{ __('admin.grant.quantity') }}</label>
<input id="grant-quantity" type="number" min="1" wire:model="quantity"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('quantity')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
</div>
@endif
<div class="mt-4 grid grid-cols-2 gap-3">
<div>
<label class="text-sm font-medium text-body" for="grant-price">{{ __('admin.grant.price') }}</label>
<div class="mt-1.5 flex items-center gap-2">
<input id="grant-price" type="text" inputmode="decimal" wire:model="priceEuros"
class="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
<span class="text-sm text-muted"></span>
</div>
@if ($cataloguePriceCents !== null)
<p class="mt-1 text-xs text-muted">
{{ __('admin.grant.catalogue_price', ['price' => Number::currency($cataloguePriceCents / 100, in: 'EUR', locale: app()->getLocale())]) }}
</p>
@endif
@error('priceEuros')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="grant-until">{{ __('admin.grant.until_label') }}</label>
<input id="grant-until" type="date" wire:model="until"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
<p class="mt-1 text-xs text-muted">{{ __('admin.grant.until_hint') }}</p>
</div>
</div>
<div class="mt-4">
<label class="text-sm font-medium text-body" for="grant-note">{{ __('admin.grant.note') }}</label>
<textarea id="grant-note" rows="2" wire:model="note"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink"></textarea>
@error('note')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('admin.grant.cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="grant" wire:loading.attr="disabled">
<x-ui.icon name="tag" class="size-4" />{{ __('admin.grant.submit') }}
</x-ui.button>
</div>
</div>

View File

@ -0,0 +1,109 @@
<?php
use App\Livewire\Admin\Customers;
use App\Livewire\Admin\GrantPlan;
use App\Models\Customer;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
/**
* The console's side of granting: a modal (R20) on the customer's own row,
* gated by its own capability rather than merely hidden (mutation target #3).
*
* 'fsn' is seeded by the datacenters migration itself (baseline data, like
* the mailbox seeding) no factory needed for it here.
*/
it('refuses to open for an operator without the capability, not merely hides the button', function () {
$customer = Customer::factory()->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'));
});