Measure availability, and let a customer move down again
Availability is now counted from our own monitoring answers. Uptime Kuma knows the figure, but the REST bridge in front of it only answers "healthy, yes or no", and waiting for the bridge to grow an endpoint would have left the panel without the number indefinitely. The sync already asks on a schedule; counting the answers gives a percentage that is ours and explainable. An unanswered check is not a failed one — the counter only moves when a real answer arrived, so our monitoring going down does not become the customer's outage on the one figure they are asked to trust. An instance nobody has ever checked shows no availability rather than 100 %. The downgrade path existed nowhere. Going down is not the mirror of going up: it can ask an instance to hold more than the target plan allows. Smaller plans are now listed even when they cannot be taken, with the obstacle in the customer's own numbers — "you have 31 users, this package allows 10" — because a greyed-out button that does not name what is in the way is the thing people ring about. Storage is checked against the last real reading rather than the contractual allowance, or anyone who once bought a large plan could never leave it. The limit is re-checked in the action: a rule enforced only in markup is not enforced. And the smaller corrections from this round: - The cloud tab carried a hardcoded "B" as the instance's initial, and the PHP and MariaDB versions, which tell a tenant nothing they can act on. - "EU — Serverstandort im Angebot festgehalten" under a label already reading "Serverstandort" was two sentences saying nothing. It is the country. - Support promised a reply "binnen 4 Std." with nothing behind it. What is true everywhere else is an answer on the same working day. - The actions column appeared even when no row in the table had an action. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/portal-design
parent
ee5e92c0ed
commit
85fcc154b8
|
|
@ -7,6 +7,7 @@ use App\Models\Customer;
|
|||
use App\Models\Order;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Billing\AddonCatalogue;
|
||||
use App\Services\Billing\DowngradeCheck;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Layout;
|
||||
|
|
@ -34,7 +35,7 @@ class Billing extends Component
|
|||
$addons = (array) config('provisioning.addons');
|
||||
|
||||
[$plan, $amount, $addonKey] = match ($type) {
|
||||
'upgrade' => [$key, (int) ($plans[$key]['price_cents'] ?? 0), null],
|
||||
'upgrade', 'downgrade' => [$key, (int) ($plans[$key]['price_cents'] ?? 0), null],
|
||||
'storage' => [$currentPlan, (int) config('provisioning.storage_addon.price_cents', 0), null],
|
||||
'traffic' => [$currentPlan, (int) config('provisioning.traffic.addon.price_cents', 0), null],
|
||||
'addon' => [$currentPlan, (int) ($addons[$key]['price_cents'] ?? 0), $key],
|
||||
|
|
@ -48,6 +49,13 @@ class Billing extends Component
|
|||
// one and charge for the privilege.
|
||||
'upgrade' => isset($plans[$key])
|
||||
&& (int) ($plans[$key]['tier'] ?? 0) > (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0),
|
||||
// Re-checked here and not only in the view: the button can be
|
||||
// hidden and the action still called — a stale tab, a second
|
||||
// window, anyone with the component name. A limit enforced only in
|
||||
// markup is not enforced.
|
||||
'downgrade' => isset($plans[$key])
|
||||
&& (int) ($plans[$key]['tier'] ?? 0) < (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0)
|
||||
&& DowngradeCheck::for($customer, $instance, $plans[$key])->allowed,
|
||||
'storage' => true,
|
||||
// Always available: running out of traffic is exactly when someone
|
||||
// needs to be able to buy more, whatever plan they are on.
|
||||
|
|
@ -73,11 +81,13 @@ class Billing extends Component
|
|||
$replaced = DB::transaction(function () use ($customer, $type, $plan, $addonKey, $amount, $datacenter) {
|
||||
Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
|
||||
|
||||
$replaced = $type === 'upgrade'
|
||||
// Up and down are the same kind of change and contradict each
|
||||
// other just as much, so either replaces a pending one of both.
|
||||
$replaced = in_array($type, ['upgrade', 'downgrade'], true)
|
||||
? Order::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->where('status', 'pending')
|
||||
->where('type', 'upgrade')
|
||||
->whereIn('type', ['upgrade', 'downgrade'])
|
||||
->delete()
|
||||
: 0;
|
||||
|
||||
|
|
@ -147,6 +157,16 @@ class Billing extends Component
|
|||
// customer immediately for losing resources.
|
||||
$currentTier = (int) ($instance?->subscription?->tier ?? $plans[$currentKey]['tier'] ?? 0);
|
||||
|
||||
// Smaller plans, each with the reason it cannot be taken today. Shown
|
||||
// WITH the reason rather than hidden: a customer who cannot downgrade
|
||||
// needs to know what to delete, and a plan that silently disappears
|
||||
// reads as "not offered any more".
|
||||
$downgrades = collect($plans)
|
||||
->filter(fn ($p) => (int) ($p['tier'] ?? 0) < $currentTier)
|
||||
->sortByDesc(fn ($p) => (int) ($p['tier'] ?? 0))
|
||||
->map(fn ($p, $k) => $p + ['check' => DowngradeCheck::for($customer, $instance, $p)])
|
||||
->all();
|
||||
|
||||
$upgrades = collect($plans)
|
||||
->filter(fn ($p) => (int) ($p['tier'] ?? 0) > $currentTier)
|
||||
->keys()->all();
|
||||
|
|
@ -162,6 +182,7 @@ class Billing extends Component
|
|||
'plans' => $plans,
|
||||
'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(),
|
||||
'upgrades' => $upgrades,
|
||||
'downgrades' => $downgrades,
|
||||
'storage' => (array) config('provisioning.storage_addon'),
|
||||
'trafficAddon' => (array) config('provisioning.traffic.addon'),
|
||||
// Resolved once: the cart and the plan cards must not state
|
||||
|
|
|
|||
|
|
@ -54,8 +54,6 @@ class Cloud extends Component
|
|||
'price' => (int) round(($contract?->monthlyPriceCents() ?? 0) / 100),
|
||||
]),
|
||||
'location' => __('cloud.datacenter'),
|
||||
'version' => 'Nextcloud 31.0.4',
|
||||
'php' => 'PHP 8.3 · MariaDB 11.4',
|
||||
'storageUsed' => $used,
|
||||
'storageQuota' => $quota,
|
||||
'seats' => __('billing.seats_count', ['count' => $contract?->seats ?? 0]),
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ class Dashboard extends Component
|
|||
// fortnight, and drawing one at zero would tell its owner their
|
||||
// data had vanished.
|
||||
'disk' => $instance !== null ? $this->disk($instance) : null,
|
||||
'availability' => $instance !== null ? InstanceMetric::availability($instance) : null,
|
||||
'availabilityTrend' => $instance !== null ? InstanceMetric::availabilitySeries($instance) : [],
|
||||
'seatBreakdown' => $this->seatBreakdown($customer),
|
||||
'trend' => $instance !== null
|
||||
? InstanceMetric::series($instance)->map(fn (InstanceMetric $m) => $m->rx_bytes + $m->tx_bytes)->all()
|
||||
: [],
|
||||
|
|
@ -74,6 +77,28 @@ class Dashboard extends Component
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who holds the seats, so the card can say something true underneath the
|
||||
* figure rather than repeating it.
|
||||
*
|
||||
* @return array<string, int> role => count, roles with nobody omitted
|
||||
*/
|
||||
private function seatBreakdown(?Customer $customer): array
|
||||
{
|
||||
if ($customer === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Seat::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->whereIn('status', ['active', 'invited'])
|
||||
->selectRaw('role, COUNT(*) as n')
|
||||
->groupBy('role')
|
||||
->pluck('n', 'role')
|
||||
->filter()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* How full the instance is, as measured — not as sold.
|
||||
*
|
||||
|
|
@ -81,7 +106,7 @@ class Dashboard extends Component
|
|||
* contractual allowance, which is true, rather than a ring at a level
|
||||
* nobody measured.
|
||||
*
|
||||
* @return array{used: int, total: int, percent: float}|null
|
||||
* @return array{used: int, total: int, percent: float, week_delta: int|null}|null
|
||||
*/
|
||||
private function disk(Instance $instance): ?array
|
||||
{
|
||||
|
|
@ -91,10 +116,22 @@ class Dashboard extends Component
|
|||
return null;
|
||||
}
|
||||
|
||||
// What it grew by this week, which is the line the template carries
|
||||
// under the ring. Null when there is no reading a week back to compare
|
||||
// against — an instance three days old has no weekly trend, and
|
||||
// inventing "+0 GB" would read as "nothing happened".
|
||||
$weekAgo = InstanceMetric::query()
|
||||
->where('instance_id', $instance->id)
|
||||
->whereNotNull('disk_used_bytes')
|
||||
->where('day', '<=', now()->subDays(7)->toDateString())
|
||||
->orderByDesc('day')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'used' => $metric->disk_used_bytes,
|
||||
'total' => $metric->disk_total_bytes,
|
||||
'percent' => round($metric->disk_used_bytes / max(1, $metric->disk_total_bytes) * 100, 1),
|
||||
'week_delta' => $weekAgo !== null ? $metric->disk_used_bytes - $weekAgo->disk_used_bytes : null,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -193,7 +193,13 @@ class Users extends Component
|
|||
$customer = $this->customer();
|
||||
$seats = $customer ? $customer->seats()->orderByRaw("role = 'owner' desc")->orderBy('email')->get() : collect();
|
||||
|
||||
// The owner can be neither re-invited nor revoked, so a table holding
|
||||
// only the owner has nothing to act on and should not carry a column
|
||||
// of empty cells.
|
||||
$hasActions = $seats->contains(fn ($seat) => $seat->role !== 'owner' || $seat->status === 'invited');
|
||||
|
||||
return view('livewire.users', [
|
||||
'hasActions' => $hasActions,
|
||||
'seats' => $seats,
|
||||
'used' => $customer ? $this->usedSeats($customer) : 0,
|
||||
'limit' => $customer ? $this->seatLimit($customer) : 0,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use Illuminate\Support\Collection;
|
|||
*/
|
||||
class InstanceMetric extends Model
|
||||
{
|
||||
protected $fillable = ['instance_id', 'day', 'disk_used_bytes', 'disk_total_bytes', 'rx_bytes', 'tx_bytes'];
|
||||
protected $fillable = ['instance_id', 'day', 'disk_used_bytes', 'disk_total_bytes', 'rx_bytes', 'tx_bytes', 'checks_total', 'checks_ok'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
|
|
@ -27,6 +27,8 @@ class InstanceMetric extends Model
|
|||
'disk_total_bytes' => 'integer',
|
||||
'rx_bytes' => 'integer',
|
||||
'tx_bytes' => 'integer',
|
||||
'checks_total' => 'integer',
|
||||
'checks_ok' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +55,46 @@ class InstanceMetric extends Model
|
|||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Availability over the window, as a percentage, or null.
|
||||
*
|
||||
* Null when nothing was ever checked. Reporting an unmonitored instance as
|
||||
* 100 % is the single most dishonest number this application could print —
|
||||
* it is the figure a customer would quote to their own auditor.
|
||||
*/
|
||||
public static function availability(Instance $instance, int $days = 30): ?float
|
||||
{
|
||||
$row = self::query()
|
||||
->where('instance_id', $instance->id)
|
||||
->where('day', '>=', Carbon::today()->subDays($days - 1))
|
||||
->selectRaw('SUM(checks_total) as total, SUM(checks_ok) as ok')
|
||||
->first();
|
||||
|
||||
$total = (int) ($row->total ?? 0);
|
||||
|
||||
return $total === 0 ? null : round((int) $row->ok / $total * 100, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily availability for the trend, oldest first.
|
||||
*
|
||||
* Days without a single check are skipped rather than drawn at zero: our
|
||||
* monitoring being down is not the customer's cloud being down.
|
||||
*
|
||||
* @return array<int, float>
|
||||
*/
|
||||
public static function availabilitySeries(Instance $instance, int $days = 30): array
|
||||
{
|
||||
return self::query()
|
||||
->where('instance_id', $instance->id)
|
||||
->where('day', '>=', Carbon::today()->subDays($days - 1))
|
||||
->where('checks_total', '>', 0)
|
||||
->orderBy('day')
|
||||
->get()
|
||||
->map(fn (self $m) => round($m->checks_ok / max(1, $m->checks_total) * 100, 2))
|
||||
->all();
|
||||
}
|
||||
|
||||
/** The most recent day that carries a disk reading, or null. */
|
||||
public static function latestDisk(Instance $instance): ?self
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Provisioning\Jobs;
|
||||
|
||||
use App\Models\InstanceMetric;
|
||||
use App\Models\MonitoringTarget;
|
||||
use App\Services\Monitoring\MonitoringClient;
|
||||
use Illuminate\Bus\Queueable;
|
||||
|
|
@ -29,6 +30,30 @@ class SyncMonitoringStatus implements ShouldQueue
|
|||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* One tick of the availability figure the customer panel shows.
|
||||
*
|
||||
* Written per day so thirty days is thirty rows, and so a gap in our own
|
||||
* monitoring shows up as a day with fewer checks rather than as downtime
|
||||
* that never happened.
|
||||
*/
|
||||
private function countCheck(MonitoringTarget $target, ?bool $healthy): void
|
||||
{
|
||||
if ($healthy === null || $target->instance_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$metric = InstanceMetric::query()->firstOrCreate(
|
||||
['instance_id' => $target->instance_id, 'day' => now()->toDateString()],
|
||||
);
|
||||
|
||||
$metric->increment('checks_total');
|
||||
|
||||
if ($healthy) {
|
||||
$metric->increment('checks_ok');
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(MonitoringClient $monitoring): void
|
||||
{
|
||||
MonitoringTarget::query()
|
||||
|
|
@ -60,6 +85,12 @@ class SyncMonitoringStatus implements ShouldQueue
|
|||
return;
|
||||
}
|
||||
|
||||
// Counted before the early return below, so a day's denominator grows
|
||||
// only when a real answer arrived. An unanswered check is not a failed
|
||||
// one — folding the two together turns a monitoring outage into the
|
||||
// customer's outage, on the one figure they are asked to trust.
|
||||
$this->countCheck($target, $healthy);
|
||||
|
||||
if ($healthy === null) {
|
||||
// No answer. The previous verdict stays and is allowed to go stale,
|
||||
// which readers show as "not monitored" rather than as health.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
use App\Models\InstanceMetric;
|
||||
use App\Models\Seat;
|
||||
use App\Support\Bytes;
|
||||
|
||||
/**
|
||||
* Whether a customer may move down to a smaller plan, and if not, why.
|
||||
*
|
||||
* A downgrade is not the mirror of an upgrade. Going up always fits; going
|
||||
* down can ask an instance to hold more than the target plan allows, and the
|
||||
* failure has to be explained in the customer's own numbers — "you have 31
|
||||
* users, Start allows 10" — rather than as a disabled button. A greyed-out
|
||||
* control that does not say what is in the way is the thing people ring about.
|
||||
*
|
||||
* Checked against what is MEASURED, not what was sold: a customer sitting on
|
||||
* 480 GB of a 500 GB plan cannot move to a 200 GB one, whatever their contract
|
||||
* says the allowance is.
|
||||
*/
|
||||
final readonly class DowngradeCheck
|
||||
{
|
||||
private function __construct(
|
||||
public bool $allowed,
|
||||
/** @var array<int, array{key: string, current: string, limit: string}> */
|
||||
public array $blockers,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $target the catalogue entry being moved to
|
||||
*/
|
||||
public static function for(Customer $customer, ?Instance $instance, array $target): self
|
||||
{
|
||||
$blockers = [];
|
||||
|
||||
$seats = Seat::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->whereIn('status', ['active', 'invited'])
|
||||
->count();
|
||||
|
||||
$seatLimit = (int) ($target['seats'] ?? 0);
|
||||
|
||||
if ($seatLimit > 0 && $seats > $seatLimit) {
|
||||
$blockers[] = [
|
||||
'key' => 'seats',
|
||||
'current' => (string) $seats,
|
||||
'limit' => (string) $seatLimit,
|
||||
];
|
||||
}
|
||||
|
||||
// Storage is checked against the last real reading. Falling back to the
|
||||
// contractual allowance would refuse a downgrade to anyone who has ever
|
||||
// bought a large plan, however empty their instance is.
|
||||
$quotaGb = (int) ($target['quota_gb'] ?? 0);
|
||||
$metric = $instance !== null ? InstanceMetric::latestDisk($instance) : null;
|
||||
|
||||
if ($quotaGb > 0 && $metric !== null && $metric->disk_used_bytes > $quotaGb * 1024 ** 3) {
|
||||
$blockers[] = [
|
||||
'key' => 'storage',
|
||||
'current' => Bytes::human($metric->disk_used_bytes),
|
||||
'limit' => $quotaGb.' GB',
|
||||
];
|
||||
}
|
||||
|
||||
return new self($blockers === [], $blockers);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Availability, counted from our own checks.
|
||||
*
|
||||
* Uptime Kuma knows the figure but the REST bridge in front of it only answers
|
||||
* "healthy, yes or no" — and waiting for the bridge to grow an endpoint would
|
||||
* leave the panel without the number indefinitely. The monitoring sync already
|
||||
* asks that question on a schedule; counting the answers gives a percentage
|
||||
* that is ours, explainable, and available today.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('instance_metrics', function (Blueprint $table) {
|
||||
$table->unsignedInteger('checks_total')->default(0);
|
||||
$table->unsignedInteger('checks_ok')->default(0);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('instance_metrics', function (Blueprint $table) {
|
||||
$table->dropColumn(['checks_total', 'checks_ok']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,6 +1,18 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Kleinere Pakete werden auch dann gezeigt, wenn sie gerade nicht gehen —
|
||||
// mit dem Grund in den Zahlen des Kunden.
|
||||
'downgrade' => 'Kleineres Paket',
|
||||
'downgrade_hint' => 'Sie können jederzeit herunterwechseln, solange Ihr Bestand in das kleinere Paket passt.',
|
||||
'downgrade_to' => 'Auf :plan wechseln',
|
||||
'downgrade_blocked' => 'Derzeit nicht möglich',
|
||||
'downgrade_blocker' => [
|
||||
'seats' => 'Sie haben :current Benutzer, dieses Paket erlaubt :limit. Entfernen Sie Zugänge unter „Benutzer“.',
|
||||
'storage' => 'Sie belegen :current, dieses Paket bietet :limit. Löschen Sie Daten oder leeren Sie den Papierkorb.',
|
||||
],
|
||||
'per_month' => 'netto pro Monat',
|
||||
|
||||
'net_reverse_charge' => 'netto — Reverse Charge',
|
||||
'net_per_month' => 'netto pro Monat',
|
||||
'net_once' => 'netto, einmalig',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ return [
|
|||
'title' => 'Meine Cloud',
|
||||
'subtitle' => 'Details und Ressourcen Ihrer Instanz.',
|
||||
'plan_line' => ':plan · :price €/Monat',
|
||||
'datacenter' => 'EU — Serverstandort im Angebot festgehalten',
|
||||
'datacenter' => 'EU',
|
||||
'now' => 'jetzt',
|
||||
'instance_label' => 'Instanz',
|
||||
'open' => 'Cloud öffnen',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
return [
|
||||
'traffic' => [
|
||||
'title' => 'Datenvolumen diesen Monat',
|
||||
'label' => 'Datenvolumen',
|
||||
'of' => 'von :total',
|
||||
'resets' => 'setzt sich in :days Tagen zurück',
|
||||
'remaining' => ':amount übrig',
|
||||
|
|
@ -32,11 +33,14 @@ return [
|
|||
'package' => 'Paket',
|
||||
'operation' => 'Betriebsform',
|
||||
'last_backup' => 'Letzte Sicherung',
|
||||
'availability' => 'Verfügbarkeit',
|
||||
'availability_note' => 'letzte 30 Tage · gemessen',
|
||||
'location' => 'Serverstandort',
|
||||
'users' => 'Benutzer',
|
||||
'address' => 'Adresse',
|
||||
'storage' => 'Speicher',
|
||||
'storage_used' => ':percent % belegt',
|
||||
'storage_week' => ':amount diese Woche',
|
||||
'storage_note' => 'vertraglich zugesichert',
|
||||
'location_note' => 'kein Drittlandtransfer',
|
||||
'gb' => ':n GB',
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ return [
|
|||
'contact_hours_val' => 'Mo–Fr, 08–18 Uhr',
|
||||
'contact_email' => 'E-Mail',
|
||||
'contact_sla' => 'Reaktionszeit',
|
||||
'contact_sla_val' => 'Team-Paket · binnen 4 Std.',
|
||||
'contact_sla_val' => 'Antwort am selben Werktag',
|
||||
'tickets' => 'Ihre Anfragen',
|
||||
'status_open' => 'Offen',
|
||||
'status_progress' => 'In Bearbeitung',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,16 @@ return [
|
|||
'role_member' => 'Mitglied',
|
||||
'role_readonly' => 'Nur Lesen',
|
||||
|
||||
// Counted next to the seat figure, so the card says something true
|
||||
// underneath the number instead of repeating it.
|
||||
'role_count' => [
|
||||
'owner' => '{1} :count Inhaber|[2,*] :count Inhaber',
|
||||
'admin' => '{1} :count Administrator|[2,*] :count Administratoren',
|
||||
'member' => '{1} :count Mitglied|[2,*] :count Mitglieder',
|
||||
'readonly' => '{1} :count Nur Lesen|[2,*] :count Nur Lesen',
|
||||
'guest' => '{1} :count Gastzugang|[2,*] :count Gastzugänge',
|
||||
],
|
||||
|
||||
'col_person' => 'Person',
|
||||
'col_status' => 'Status',
|
||||
'owner_no_actions' => 'Der Inhaber kann nicht entfernt werden.',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Smaller plans are listed even when they cannot be taken, with the reason
|
||||
// in the customer's own numbers.
|
||||
'downgrade' => 'Smaller package',
|
||||
'downgrade_hint' => 'You can move down at any time, as long as what you hold fits the smaller package.',
|
||||
'downgrade_to' => 'Move to :plan',
|
||||
'downgrade_blocked' => 'Not possible right now',
|
||||
'downgrade_blocker' => [
|
||||
'seats' => 'You have :current users, this package allows :limit. Remove access under "Users".',
|
||||
'storage' => 'You are using :current, this package offers :limit. Delete data or empty the trash.',
|
||||
],
|
||||
'per_month' => 'net per month',
|
||||
|
||||
'net_reverse_charge' => 'net — reverse charge',
|
||||
'net_per_month' => 'net per month',
|
||||
'net_once' => 'net, one-off',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ return [
|
|||
'title' => 'My cloud',
|
||||
'subtitle' => 'Details and resources of your instance.',
|
||||
'plan_line' => ':plan · €:price/mo',
|
||||
'datacenter' => 'EU — server location fixed in your offer',
|
||||
'datacenter' => 'EU',
|
||||
'now' => 'now',
|
||||
'instance_label' => 'Instance',
|
||||
'open' => 'Open cloud',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
return [
|
||||
'traffic' => [
|
||||
'title' => 'Data transfer this month',
|
||||
'label' => 'Data transfer',
|
||||
'of' => 'of :total',
|
||||
'resets' => 'resets in :days days',
|
||||
'remaining' => ':amount left',
|
||||
|
|
@ -32,11 +33,14 @@ return [
|
|||
'package' => 'Package',
|
||||
'operation' => 'Operating mode',
|
||||
'last_backup' => 'Last backup',
|
||||
'availability' => 'Availability',
|
||||
'availability_note' => 'last 30 days · measured',
|
||||
'location' => 'Server location',
|
||||
'users' => 'Users',
|
||||
'address' => 'Address',
|
||||
'storage' => 'Storage',
|
||||
'storage_used' => ':percent % used',
|
||||
'storage_week' => ':amount this week',
|
||||
'storage_note' => 'contractually guaranteed',
|
||||
'location_note' => 'no third-country transfer',
|
||||
'gb' => ':n GB',
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ return [
|
|||
'contact_hours_val' => 'Mon–Fri, 8am–6pm',
|
||||
'contact_email' => 'Email',
|
||||
'contact_sla' => 'Response time',
|
||||
'contact_sla_val' => 'Team plan · within 4 hrs',
|
||||
'contact_sla_val' => 'Answer on the same working day',
|
||||
'tickets' => 'Your requests',
|
||||
'status_open' => 'Open',
|
||||
'status_progress' => 'In progress',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,16 @@ return [
|
|||
'role_member' => 'Member',
|
||||
'role_readonly' => 'Read-only',
|
||||
|
||||
// Counted next to the seat figure, so the card says something true
|
||||
// underneath the number instead of repeating it.
|
||||
'role_count' => [
|
||||
'owner' => '{1} :count owner|[2,*] :count owner',
|
||||
'admin' => '{1} :count administrator|[2,*] :count administrators',
|
||||
'member' => '{1} :count member|[2,*] :count members',
|
||||
'readonly' => '{1} :count read-only|[2,*] :count read-only',
|
||||
'guest' => '{1} :count guest|[2,*] :count guests',
|
||||
],
|
||||
|
||||
'col_person' => 'Person',
|
||||
'col_status' => 'Status',
|
||||
'owner_no_actions' => 'The owner cannot be removed.',
|
||||
|
|
|
|||
|
|
@ -147,6 +147,59 @@
|
|||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ── Downgrade ──────────────────────────────────────────────────────
|
||||
Smaller plans are listed even when they cannot be taken, with the
|
||||
reason in the customer's own numbers. A plan that quietly disappears
|
||||
reads as "no longer offered"; a greyed-out button that does not say
|
||||
what is in the way is the thing people ring about. --}}
|
||||
@if ($downgrades !== [])
|
||||
<section class="animate-rise">
|
||||
<h2 class="text-md font-semibold text-ink">{{ __('billing.downgrade') }}</h2>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('billing.downgrade_hint') }}</p>
|
||||
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach ($downgrades as $key => $plan)
|
||||
<article class="flex flex-col rounded-lg border border-line bg-surface p-5 shadow-xs">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-md font-semibold text-ink">{{ $plan['name'] ?? $key }}</h3>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ $plan['quota_gb'] ?? 0 }} GB · {{ __('billing.seats_count', ['count' => $plan['seats'] ?? 0]) }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="shrink-0 text-right font-mono text-lg font-semibold text-ink">
|
||||
{{ $eur($plan['price_cents'] ?? 0) }}
|
||||
<span class="mt-0.5 block text-xs font-normal text-muted">{{ __('billing.per_month') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if ($plan['check']->allowed)
|
||||
<x-ui.button variant="secondary" class="mt-5 w-full"
|
||||
wire:click="purchase('downgrade', '{{ $key }}')"
|
||||
wire:loading.attr="disabled" wire:target="purchase('downgrade', '{{ $key }}')">
|
||||
{{ __('billing.downgrade_to', ['plan' => $plan['name'] ?? $key]) }}
|
||||
</x-ui.button>
|
||||
@else
|
||||
<div class="mt-5 rounded border border-warning-border bg-warning-bg p-3">
|
||||
<p class="text-xs font-semibold text-warning">{{ __('billing.downgrade_blocked') }}</p>
|
||||
<ul class="mt-2 space-y-1.5">
|
||||
@foreach ($plan['check']->blockers as $blocker)
|
||||
<li class="text-xs leading-relaxed text-ink">
|
||||
{{ __('billing.downgrade_blocker.'.$blocker['key'], [
|
||||
'current' => $blocker['current'],
|
||||
'limit' => $blocker['limit'],
|
||||
]) }}
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- Extra storage + Add-ons --}}
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3 xl:grid-cols-4 animate-rise [animation-delay:180ms]">
|
||||
{{-- Storage --}}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
|
||||
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="grid size-11 place-items-center rounded-lg bg-ink font-semibold text-on-accent">B</span>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-ink">{{ $instance['name'] }}</p>
|
||||
<a href="https://{{ $instance['domain'] }}" target="_blank" rel="noopener noreferrer" class="font-mono text-sm text-accent-text hover:underline">{{ $instance['domain'] }}</a>
|
||||
|
|
@ -43,8 +42,6 @@
|
|||
['cloud.seats', $instance['seats']],
|
||||
['cloud.performance', $instance['performance']],
|
||||
['cloud.location', $instance['location']],
|
||||
['cloud.version', $instance['version'].' · '.__('cloud.current')],
|
||||
['cloud.runtime', $instance['php']],
|
||||
['cloud.storage', $storageLabel],
|
||||
] as [$key, $val])
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -53,27 +53,19 @@
|
|||
</div>
|
||||
</section>
|
||||
@else
|
||||
{{-- ── What the customer has ─────────────────────────────────────
|
||||
The template's four cards, in its order and its form: label with a
|
||||
chevron, the figure and its unit on one line, a line of context
|
||||
underneath, the visual on the right or a bar across the foot.
|
||||
|
||||
Three of the template's visuals have no source yet — storage
|
||||
consumption is not sampled, instance_traffic keeps one row per
|
||||
period rather than a daily series, and monitoring_targets records
|
||||
a state but no uptime figure. Those cards carry the form and state
|
||||
what is true instead of drawing a chart at an invented level. This
|
||||
is the sheet a customer forwards to their auditor. --}}
|
||||
{{-- ── The four cards, in the template's order and form ──────────
|
||||
Speicher · Benutzer · Datenvolumen · Verfügbarkeit. Figure, unit
|
||||
beside it, a line of context, the visual — ring, bar, sparkline.
|
||||
Every number below is measured; nothing is drawn at a level nobody
|
||||
read. --}}
|
||||
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{{-- Storage. The ring appears once there is a reading; until then the
|
||||
card states the allowance, which is true, rather than drawing a
|
||||
level nobody measured. --}}
|
||||
@if ($disk)
|
||||
<x-ui.metric
|
||||
:label="__('dashboard.master.storage')"
|
||||
:value="\Illuminate\Support\Number::format($disk['used'] / 1024 ** 3, precision: 0, locale: $locale)"
|
||||
:unit="'/ '.\Illuminate\Support\Number::format($disk['total'] / 1024 ** 3, precision: 0, locale: $locale).' GB'"
|
||||
:foot="__('dashboard.master.storage_used', ['percent' => \Illuminate\Support\Number::format($disk['percent'], precision: 0, locale: $locale)])"
|
||||
:value="Number::format($disk['used'] / 1024 ** 3, precision: 0, locale: $locale)"
|
||||
:unit="'/ '.Number::format($disk['total'] / 1024 ** 3, precision: 0, locale: $locale).' GB'"
|
||||
:foot="trim(__('dashboard.master.storage_used', ['percent' => Number::format($disk['percent'], precision: 0, locale: $locale)])
|
||||
.($disk['week_delta'] !== null ? ' · '.__('dashboard.master.storage_week', ['amount' => ($disk['week_delta'] >= 0 ? '+' : '−').Bytes::human(abs($disk['week_delta']))]) : ''))"
|
||||
:href="route('cloud')"
|
||||
class="animate-rise [animation-delay:60ms]"
|
||||
>
|
||||
|
|
@ -104,14 +96,21 @@
|
|||
style="width: {{ min(100, (int) round($seats['used'] / max(1, $seats['total']) * 100)) }}%"></div>
|
||||
</div>
|
||||
@endif
|
||||
@if ($seatBreakdown !== [])
|
||||
<p class="mt-2.5 text-xs text-muted">
|
||||
{{ collect($seatBreakdown)
|
||||
->map(fn (int $n, string $role) => trans_choice('users.role_count.'.$role, $n, ['count' => $n]))
|
||||
->join(' · ') }}
|
||||
</p>
|
||||
@endif
|
||||
</x-ui.metric>
|
||||
|
||||
@if ($traffic)
|
||||
@php $state = $traffic->state(); @endphp
|
||||
<x-ui.metric
|
||||
:label="__('dashboard.traffic.title')"
|
||||
:value="Bytes::human($traffic->usedBytes)"
|
||||
:unit="__('dashboard.traffic.of', ['total' => Bytes::human($traffic->quotaBytes)])"
|
||||
:label="__('dashboard.traffic.label')"
|
||||
:value="Number::format($traffic->usedBytes / 1024 ** 3, precision: 0, locale: $locale)"
|
||||
:unit="'GB / '.Bytes::human($traffic->quotaBytes)"
|
||||
:foot="__('dashboard.traffic.resets', ['days' => $traffic->daysUntilReset()])"
|
||||
:href="route('billing')"
|
||||
class="animate-rise [animation-delay:140ms]"
|
||||
|
|
@ -123,19 +122,7 @@
|
|||
<x-slot:visual><x-ui.spark :points="$trend" area /></x-slot:visual>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 h-1.5 overflow-hidden rounded-pill bg-surface-hover">
|
||||
<div @class([
|
||||
'h-full rounded-pill',
|
||||
'bg-accent' => $state === 'ok',
|
||||
'bg-warning' => $state === 'warn' || $state === 'critical',
|
||||
'bg-danger' => $state === 'exhausted',
|
||||
])
|
||||
style="width: {{ min(100, $traffic->percent()) }}%"></div>
|
||||
</div>
|
||||
@if ($state !== 'ok')
|
||||
{{-- The offer belongs next to the warning: someone
|
||||
reading that they are nearly out should not have to
|
||||
go looking for the way to fix it. --}}
|
||||
<a href="{{ route('billing') }}" wire:navigate class="mt-3 inline-flex items-center gap-1.5 text-xs font-semibold text-accent-text hover:underline">
|
||||
<x-ui.icon name="plus" class="size-3.5" />{{ __('dashboard.traffic.buy') }}
|
||||
</a>
|
||||
|
|
@ -143,20 +130,23 @@
|
|||
</x-ui.metric>
|
||||
@endif
|
||||
|
||||
{{-- Where the template shows availability. There is no uptime
|
||||
figure to show, so this card carries the thing this product is
|
||||
actually about and can prove: when the last backup ran and
|
||||
whether it was verified. --}}
|
||||
@php $lastBackup = collect($proofs)->firstWhere('key', 'backup'); @endphp
|
||||
@if ($lastBackup)
|
||||
{{-- Availability, counted from our own monitoring answers. Absent
|
||||
rather than 100 % when nothing has been checked: reporting an
|
||||
unmonitored instance as perfect is the single most dishonest
|
||||
number this page could carry. --}}
|
||||
@if ($availability !== null)
|
||||
<x-ui.metric
|
||||
:label="__('dashboard.master.last_backup')"
|
||||
:value="$lastBackup['at']?->locale($locale)->isoFormat('HH:mm') ?? '—'"
|
||||
:unit="$lastBackup['at']?->locale($locale)->isoFormat('DD.MM.')"
|
||||
:foot="__('dashboard.proofs.state.'.$lastBackup['state']).' · '.($lastBackup['note'] ?? '')"
|
||||
:href="route('backups')"
|
||||
:label="__('dashboard.master.availability')"
|
||||
:value="Number::format($availability, precision: 2, locale: $locale)"
|
||||
unit="%"
|
||||
:foot="__('dashboard.master.availability_note')"
|
||||
:href="route('cloud')"
|
||||
class="animate-rise [animation-delay:180ms]"
|
||||
/>
|
||||
>
|
||||
@if (count($availabilityTrend) > 2)
|
||||
<x-slot:visual><x-ui.spark :points="$availabilityTrend" tone="muted" /></x-slot:visual>
|
||||
@endif
|
||||
</x-ui.metric>
|
||||
@elseif ($location)
|
||||
<x-ui.metric
|
||||
:label="__('dashboard.master.location')"
|
||||
|
|
|
|||
|
|
@ -44,7 +44,9 @@
|
|||
<th class="px-4 py-3 font-semibold">{{ __('users.col_person') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('users.role') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('users.col_status') }}</th>
|
||||
<th class="px-4 py-3 text-right font-semibold">{{ __('users.col_actions') }}</th>
|
||||
@if ($hasActions)
|
||||
<th class="px-4 py-3 text-right font-semibold">{{ __('users.col_actions') }}</th>
|
||||
@endif
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -69,6 +71,7 @@
|
|||
@php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp
|
||||
<x-ui.badge :status="$sb">{{ __('users.status_'.$seat->status) }}</x-ui.badge>
|
||||
</td>
|
||||
@if ($hasActions)
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-1.5">
|
||||
@if ($seat->role === 'owner')
|
||||
|
|
@ -85,6 +88,7 @@
|
|||
@endif
|
||||
</div>
|
||||
</td>
|
||||
@endif
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Billing;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
use App\Models\InstanceMetric;
|
||||
use App\Models\Order;
|
||||
use App\Models\Seat;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\DowngradeCheck;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Moving down to a smaller plan.
|
||||
*
|
||||
* A downgrade is not the mirror of an upgrade: going up always fits, going
|
||||
* down can ask the instance to hold more than the target allows. What matters
|
||||
* as much as refusing it is SAYING WHY, in the customer's own numbers — a
|
||||
* disabled button that does not name the obstacle is the thing people ring
|
||||
* about.
|
||||
*/
|
||||
|
||||
function downgradeCustomer(int $seats = 1, ?int $usedBytes = null): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$customer = Customer::factory()->create(['user_id' => $user->id]);
|
||||
$instance = Instance::factory()->create(['customer_id' => $customer->id, 'status' => 'active', 'quota_gb' => 500]);
|
||||
|
||||
foreach (range(1, $seats) as $n) {
|
||||
Seat::create([
|
||||
'customer_id' => $customer->id,
|
||||
'email' => "seat{$n}@example.test",
|
||||
'role' => $n === 1 ? 'owner' : 'member',
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($usedBytes !== null) {
|
||||
InstanceMetric::create([
|
||||
'instance_id' => $instance->id,
|
||||
'day' => now()->toDateString(),
|
||||
'disk_used_bytes' => $usedBytes,
|
||||
'disk_total_bytes' => 500 * 1024 ** 3,
|
||||
]);
|
||||
}
|
||||
|
||||
return [$user, $customer, $instance];
|
||||
}
|
||||
|
||||
it('allows a downgrade when everything fits', function () {
|
||||
[, $customer, $instance] = downgradeCustomer(seats: 3, usedBytes: 40 * 1024 ** 3);
|
||||
|
||||
$check = DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200]);
|
||||
|
||||
expect($check->allowed)->toBeTrue()->and($check->blockers)->toBe([]);
|
||||
});
|
||||
|
||||
it('names the seats that are in the way, with both numbers', function () {
|
||||
[, $customer, $instance] = downgradeCustomer(seats: 31);
|
||||
|
||||
$check = DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200]);
|
||||
|
||||
expect($check->allowed)->toBeFalse()
|
||||
->and($check->blockers[0]['key'])->toBe('seats')
|
||||
->and($check->blockers[0]['current'])->toBe('31')
|
||||
->and($check->blockers[0]['limit'])->toBe('10');
|
||||
});
|
||||
|
||||
it('measures storage rather than trusting the allowance', function () {
|
||||
// 480 GB really in use. The contract says 500 GB, but what decides whether
|
||||
// 200 GB is enough is what is actually stored.
|
||||
[, $customer, $instance] = downgradeCustomer(seats: 2, usedBytes: 480 * 1024 ** 3);
|
||||
|
||||
$check = DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200]);
|
||||
|
||||
expect($check->allowed)->toBeFalse()
|
||||
->and(collect($check->blockers)->pluck('key')->all())->toBe(['storage']);
|
||||
});
|
||||
|
||||
it('does not refuse a customer who has simply never been measured', function () {
|
||||
// A new instance has no reading. Refusing on the contractual allowance
|
||||
// would block every downgrade for anyone who once bought a large plan.
|
||||
[, $customer, $instance] = downgradeCustomer(seats: 2, usedBytes: null);
|
||||
|
||||
expect(DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200])->allowed)->toBeTrue();
|
||||
});
|
||||
|
||||
it('refuses a blocked downgrade even when the button is never rendered', function () {
|
||||
// The action is callable from a stale tab or a second window. A limit
|
||||
// enforced only in markup is not enforced.
|
||||
[$user, $customer, $instance] = downgradeCustomer(seats: 31);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Billing::class)
|
||||
->call('purchase', 'downgrade', 'start');
|
||||
|
||||
expect(Order::query()->where('customer_id', $customer->id)->where('type', 'downgrade')->count())->toBe(0);
|
||||
});
|
||||
|
|
@ -175,7 +175,7 @@ it('shows the customer what is left, and offers a top-up when it gets tight', fu
|
|||
|
||||
// Comfortable: no upsell in the customer's face.
|
||||
Livewire\Livewire::actingAs($user)->test(Dashboard::class)
|
||||
->assertSee(__('dashboard.traffic.title'))
|
||||
->assertSee(__('dashboard.traffic.label'))
|
||||
->assertDontSee(__('dashboard.traffic.buy'));
|
||||
|
||||
InstanceTraffic::query()->update(['tx_bytes' => (int) (1000 * 1000 ** 3 * 0.9)]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue