Grant a package or module from the customer's row in the console
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
parent
c71b2c362b
commit
3b85e8f1d1
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -24,7 +24,15 @@
|
|||
<p class="font-medium text-ink">{{ $r['name'] }}</p>
|
||||
<p class="font-mono text-xs text-muted">{{ $r['instance'] }}.clupilot.com</p>
|
||||
</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"><x-ui.badge :status="$r['status']">{{ __('admin.status.'.$r['status']) }}</x-ui.badge></td>
|
||||
<td class="px-4 py-3">
|
||||
|
|
@ -36,6 +44,14 @@
|
|||
{{ $r['suspended'] ? __('admin.reactivate') : __('admin.suspend') }}
|
||||
</button>
|
||||
@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
|
||||
is administrator access to their Nextcloud itself. Two
|
||||
different things, so two buttons. --}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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'));
|
||||
});
|
||||
Loading…
Reference in New Issue