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)]);