From 85fcc154b8ecb0906b634e1159f77d97630c6be6 Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 27 Jul 2026 16:41:15 +0200 Subject: [PATCH] Measure availability, and let a customer move down again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Livewire/Billing.php | 27 ++++- app/Livewire/Cloud.php | 2 - app/Livewire/Dashboard.php | 39 +++++++- app/Livewire/Users.php | 6 ++ app/Models/InstanceMetric.php | 44 ++++++++- .../Jobs/SyncMonitoringStatus.php | 31 ++++++ app/Services/Billing/DowngradeCheck.php | 70 +++++++++++++ ..._160000_add_checks_to_instance_metrics.php | 32 ++++++ lang/de/billing.php | 12 +++ lang/de/cloud.php | 2 +- lang/de/dashboard.php | 4 + lang/de/support.php | 2 +- lang/de/users.php | 10 ++ lang/en/billing.php | 12 +++ lang/en/cloud.php | 2 +- lang/en/dashboard.php | 4 + lang/en/support.php | 2 +- lang/en/users.php | 10 ++ resources/views/livewire/billing.blade.php | 53 ++++++++++ resources/views/livewire/cloud.blade.php | 3 - resources/views/livewire/dashboard.blade.php | 78 +++++++-------- resources/views/livewire/users.blade.php | 6 +- tests/Feature/DowngradeTest.php | 98 +++++++++++++++++++ tests/Feature/TrafficTest.php | 2 +- 24 files changed, 491 insertions(+), 60 deletions(-) create mode 100644 app/Services/Billing/DowngradeCheck.php create mode 100644 database/migrations/2026_07_27_160000_add_checks_to_instance_metrics.php create mode 100644 tests/Feature/DowngradeTest.php diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index 6142b61..80cdc9a 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -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 diff --git a/app/Livewire/Cloud.php b/app/Livewire/Cloud.php index 9a9df87..d5021da 100644 --- a/app/Livewire/Cloud.php +++ b/app/Livewire/Cloud.php @@ -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]), diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index acbc235..a1f087e 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -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 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, ]; } diff --git a/app/Livewire/Users.php b/app/Livewire/Users.php index dfc2c5c..42cff36 100644 --- a/app/Livewire/Users.php +++ b/app/Livewire/Users.php @@ -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, diff --git a/app/Models/InstanceMetric.php b/app/Models/InstanceMetric.php index 005bbdf..584c2aa 100644 --- a/app/Models/InstanceMetric.php +++ b/app/Models/InstanceMetric.php @@ -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 + */ + 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 { diff --git a/app/Provisioning/Jobs/SyncMonitoringStatus.php b/app/Provisioning/Jobs/SyncMonitoringStatus.php index 4eb5b29..3139a1e 100644 --- a/app/Provisioning/Jobs/SyncMonitoringStatus.php +++ b/app/Provisioning/Jobs/SyncMonitoringStatus.php @@ -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. diff --git a/app/Services/Billing/DowngradeCheck.php b/app/Services/Billing/DowngradeCheck.php new file mode 100644 index 0000000..9d333f3 --- /dev/null +++ b/app/Services/Billing/DowngradeCheck.php @@ -0,0 +1,70 @@ + */ + public array $blockers, + ) {} + + /** + * @param array $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); + } +} diff --git a/database/migrations/2026_07_27_160000_add_checks_to_instance_metrics.php b/database/migrations/2026_07_27_160000_add_checks_to_instance_metrics.php new file mode 100644 index 0000000..8d19116 --- /dev/null +++ b/database/migrations/2026_07_27_160000_add_checks_to_instance_metrics.php @@ -0,0 +1,32 @@ +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']); + }); + } +}; diff --git a/lang/de/billing.php b/lang/de/billing.php index ade0875..5dee999 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -1,6 +1,18 @@ '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', diff --git a/lang/de/cloud.php b/lang/de/cloud.php index 1652766..8461d82 100644 --- a/lang/de/cloud.php +++ b/lang/de/cloud.php @@ -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', diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php index 06a7795..c13fec7 100644 --- a/lang/de/dashboard.php +++ b/lang/de/dashboard.php @@ -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', diff --git a/lang/de/support.php b/lang/de/support.php index 54938ac..6fff1ac 100644 --- a/lang/de/support.php +++ b/lang/de/support.php @@ -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', diff --git a/lang/de/users.php b/lang/de/users.php index eab6d7b..d4a1bde 100644 --- a/lang/de/users.php +++ b/lang/de/users.php @@ -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.', diff --git a/lang/en/billing.php b/lang/en/billing.php index 3aa73a5..3ee9184 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -1,6 +1,18 @@ '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', diff --git a/lang/en/cloud.php b/lang/en/cloud.php index d64127b..9286254 100644 --- a/lang/en/cloud.php +++ b/lang/en/cloud.php @@ -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', diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php index b0dad67..31b438c 100644 --- a/lang/en/dashboard.php +++ b/lang/en/dashboard.php @@ -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', diff --git a/lang/en/support.php b/lang/en/support.php index 9c76129..cda6218 100644 --- a/lang/en/support.php +++ b/lang/en/support.php @@ -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', diff --git a/lang/en/users.php b/lang/en/users.php index a53943b..ad1105c 100644 --- a/lang/en/users.php +++ b/lang/en/users.php @@ -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.', diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php index aea5e6e..7434f4a 100644 --- a/resources/views/livewire/billing.blade.php +++ b/resources/views/livewire/billing.blade.php @@ -147,6 +147,59 @@ @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 !== []) +
+

{{ __('billing.downgrade') }}

+

{{ __('billing.downgrade_hint') }}

+ +
+ @foreach ($downgrades as $key => $plan) +
+
+
+

{{ $plan['name'] ?? $key }}

+

+ {{ $plan['quota_gb'] ?? 0 }} GB · {{ __('billing.seats_count', ['count' => $plan['seats'] ?? 0]) }} +

+
+

+ {{ $eur($plan['price_cents'] ?? 0) }} + {{ __('billing.per_month') }} +

+
+ + @if ($plan['check']->allowed) + + {{ __('billing.downgrade_to', ['plan' => $plan['name'] ?? $key]) }} + + @else +
+

{{ __('billing.downgrade_blocked') }}

+
    + @foreach ($plan['check']->blockers as $blocker) +
  • + {{ __('billing.downgrade_blocker.'.$blocker['key'], [ + 'current' => $blocker['current'], + 'limit' => $blocker['limit'], + ]) }} +
  • + @endforeach +
+
+ @endif +
+ @endforeach +
+
+ @endif + {{-- Extra storage + Add-ons --}}
{{-- Storage --}} diff --git a/resources/views/livewire/cloud.blade.php b/resources/views/livewire/cloud.blade.php index 7b0606a..98d7ea7 100644 --- a/resources/views/livewire/cloud.blade.php +++ b/resources/views/livewire/cloud.blade.php @@ -6,7 +6,6 @@
- B

{{ $instance['name'] }}

{{ $instance['domain'] }} @@ -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])
diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 1acd861..00dc260 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -53,27 +53,19 @@
@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. --}}
- {{-- 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) @@ -104,14 +96,21 @@ style="width: {{ min(100, (int) round($seats['used'] / max(1, $seats['total']) * 100)) }}%">
@endif + @if ($seatBreakdown !== []) +

+ {{ collect($seatBreakdown) + ->map(fn (int $n, string $role) => trans_choice('users.role_count.'.$role, $n, ['count' => $n])) + ->join(' · ') }} +

+ @endif @if ($traffic) @php $state = $traffic->state(); @endphp @endif -
-
$state === 'ok', - 'bg-warning' => $state === 'warn' || $state === 'critical', - 'bg-danger' => $state === 'exhausted', - ]) - style="width: {{ min(100, $traffic->percent()) }}%">
-
@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. --}} {{ __('dashboard.traffic.buy') }} @@ -143,20 +130,23 @@
@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) + > + @if (count($availabilityTrend) > 2) + + @endif + @elseif ($location) {{ __('users.col_person') }} {{ __('users.role') }} {{ __('users.col_status') }} - {{ __('users.col_actions') }} + @if ($hasActions) + {{ __('users.col_actions') }} + @endif @@ -69,6 +71,7 @@ @php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp {{ __('users.status_'.$seat->status) }} + @if ($hasActions)
@if ($seat->role === 'owner') @@ -85,6 +88,7 @@ @endif
+ @endif @endforeach diff --git a/tests/Feature/DowngradeTest.php b/tests/Feature/DowngradeTest.php new file mode 100644 index 0000000..d8700c4 --- /dev/null +++ b/tests/Feature/DowngradeTest.php @@ -0,0 +1,98 @@ +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); +}); diff --git a/tests/Feature/TrafficTest.php b/tests/Feature/TrafficTest.php index d8a857e..11f3adb 100644 --- a/tests/Feature/TrafficTest.php +++ b/tests/Feature/TrafficTest.php @@ -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)]);