peak_participants ?? 0)); } public function amountMinorForParticipants(int $count): int { $ghs = round($count * $this->pricePerParticipantGhs(), 2); return max((int) round($ghs * 100), 1); } public function amountMinorForStorageBytes(int $bytes): int { if ($bytes <= 0) { return 0; } $ghs = round($this->storageGb($bytes) * $this->pricePerGbMonthGhs(), 2); return max((int) round($ghs * 100), 1); } public function assertCanStartSession(Organization $organization, ?Room $room = null): void { $amountMinor = $this->amountMinorForParticipants(1); $message = sprintf( 'Insufficient wallet balance. Sessions are billed at GHS %.2f per participant — top up your Ladill wallet to start.', $this->pricePerParticipantGhs(), ); $affordable = $this->canAffordSafely($organization->owner_ref, $amountMinor); if ($affordable === null) { abort(503, 'Billing is temporarily unavailable. Please try again shortly.'); } abort_unless($affordable, 402, $message); } public function chargeSession(Session $session): void { $this->chargeParticipants($session); } public function chargeParticipants(Session $session): void { $session->loadMissing('room.organization'); $organization = $session->room->organization; $count = $this->participantsBillableFor($session); $amountMinor = $this->amountMinorForParticipants($count); if ($amountMinor <= 0) { return; } $this->recordUsage($organization, 'participant_count', $count, [ 'session_uuid' => $session->uuid, 'room_uuid' => $session->room->uuid, 'participants' => $count, ], $session->room->branch_id); $this->debit( $organization->owner_ref, $amountMinor, 'meet_participants', 'meet-participants-'.$session->uuid, sprintf('Meet session: %s (%d participants)', $session->room->title, $count), ); } public function chargeRecordingStorageInitial(Recording $recording): void { $recording->loadMissing('session.room.organization'); $bytes = (int) ($recording->file_size ?? 0); if ($bytes <= 0) { return; } if (! $this->attemptChargeStorageInitial( $recording, $bytes, $recording->session->room->organization, 'recording', $recording->uuid, sprintf('Meet recording storage: %s (%s GB)', $recording->session->room->title, $this->formatGb($bytes)), )) { Log::warning('Meet recording storage billing failed', ['recording_uuid' => $recording->uuid]); } } public function chargeFileStorageInitial(SessionFile $file): void { $file->loadMissing('room.organization'); $bytes = (int) ($file->file_size ?? 0); if ($bytes <= 0 || $file->drive_file_id) { return; } abort_unless( $this->attemptChargeStorageInitial( $file, $bytes, $file->room->organization, 'shared_file', $file->uuid, sprintf('Meet file storage: %s (%s GB)', $file->original_name, $this->formatGb($bytes)), ), 402, sprintf( 'Insufficient wallet balance. Storage is billed at GHS %.2f per GB per month — top up your Ladill wallet.', $this->pricePerGbMonthGhs(), ), ); } public function chargeStorageRenewal(Recording|SessionFile $asset): bool { $bytes = (int) ($asset->file_size ?? 0); if ($bytes <= 0) { return true; } if ($asset instanceof Recording) { $asset->loadMissing('session.room.organization'); $organization = $asset->session->room->organization; $label = $asset->session->room->title; $type = 'recording'; $uuid = $asset->uuid; } else { $asset->loadMissing('room.organization'); $organization = $asset->room->organization; $label = $asset->original_name; $type = 'shared_file'; $uuid = $asset->uuid; } $amountMinor = $this->amountMinorForStorageBytes($bytes); if ($amountMinor <= 0) { return true; } $base = $asset->storage_paid_until instanceof CarbonInterface ? $asset->storage_paid_until->copy() : now(); $periodEnd = $this->nextPeriodEnd($base); $reference = sprintf('meet-%s-renewal:%s:through:%s', $type, $uuid, $periodEnd->format('Y-m-d')); if (! ($this->canAffordSafely($organization->owner_ref, $amountMinor) ?? false)) { return false; } if (! $this->debit( $organization->owner_ref, $amountMinor, 'meet_storage_renewal', $reference, sprintf('Meet storage renewal: %s', $label), )) { return false; } $this->markStoragePaidThrough($asset, $periodEnd); $metric = $type === 'recording' ? 'recording_storage_bytes' : 'file_storage_bytes'; $this->recordUsage($organization, $metric, $bytes, [ 'asset_uuid' => $uuid, 'renewal' => true, ]); return true; } public function enterStorageGracePeriod(Recording|SessionFile $asset): void { $graceEndsAt = now()->addDays($this->gracePeriodDays()); $asset->update([ 'storage_grace_ends_at' => $graceEndsAt, ]); } public function processDueStorageRenewals(): array { $renewed = 0; $graced = 0; $deleted = 0; foreach ([Recording::class, SessionFile::class] as $modelClass) { $due = $modelClass::query() ->whereNotNull('storage_paid_until') ->where('storage_paid_until', '<=', now()) ->where(function ($query) { $query->whereNull('storage_grace_ends_at') ->orWhere('storage_grace_ends_at', '>', now()); }) ->get(); foreach ($due as $asset) { if ($this->chargeStorageRenewal($asset)) { $renewed++; continue; } if (! $asset->storage_grace_ends_at) { $this->enterStorageGracePeriod($asset); $graced++; } } $expired = $modelClass::query() ->whereNotNull('storage_grace_ends_at') ->where('storage_grace_ends_at', '<=', now()) ->get(); foreach ($expired as $asset) { $this->deleteUnpaidAsset($asset); $deleted++; } } return compact('renewed', 'graced', 'deleted'); } public function storageIsAccessible(Recording|SessionFile $asset): bool { if (! $asset->storage_paid_until) { return true; } if ($asset->storage_paid_until->isFuture()) { return true; } return $asset->storage_grace_ends_at instanceof CarbonInterface && $asset->storage_grace_ends_at->isFuture(); } protected function attemptChargeStorageInitial( Recording|SessionFile $asset, int $bytes, Organization $organization, string $type, string $uuid, string $description, ): bool { $amountMinor = $this->amountMinorForStorageBytes($bytes); if ($amountMinor <= 0) { return true; } if (! ($this->canAffordSafely($organization->owner_ref, $amountMinor) ?? false)) { return false; } $periodEnd = $this->nextPeriodEnd(now()); $reference = sprintf('meet-%s-storage:%s:through:%s', $type, $uuid, $periodEnd->format('Y-m-d')); if (! $this->debit($organization->owner_ref, $amountMinor, 'meet_storage', $reference, $description)) { return false; } $this->markStoragePaidThrough($asset, $periodEnd); $metric = $type === 'recording' ? 'recording_storage_bytes' : 'file_storage_bytes'; $this->recordUsage($organization, $metric, $bytes, [ 'asset_uuid' => $uuid, 'type' => $type, ]); return true; } protected function markStoragePaidThrough(Recording|SessionFile $asset, CarbonInterface $periodEnd): void { $asset->update([ 'storage_paid_until' => $periodEnd, 'storage_grace_ends_at' => null, 'storage_last_billed_at' => now(), ]); } protected function nextPeriodEnd(CarbonInterface $from): CarbonInterface { $days = max(1, (int) config('meet.billing.billing_period_days', 30)); return Carbon::instance($from)->addDays($days); } protected function recordUsage( Organization $organization, string $metric, int $quantity, array $metadata = [], ?int $branchId = null, ): void { UsageRecord::create([ 'owner_ref' => $organization->owner_ref, 'organization_id' => $organization->id, 'branch_id' => $branchId, 'metric' => $metric, 'quantity' => $quantity, 'metadata' => $metadata, 'recorded_at' => now(), ]); } /** @return bool|null null when the billing API is unreachable */ protected function canAffordSafely(string $ownerRef, int $amountMinor): ?bool { try { return $this->billing->canAfford($ownerRef, $amountMinor); } catch (\Throwable $e) { Log::warning('Meet billing can-afford check failed', [ 'owner_ref' => $ownerRef, 'amount_minor' => $amountMinor, 'error' => $e->getMessage(), ]); return null; } } protected function debit( string $ownerRef, int $amountMinor, string $source, string $reference, string $description, ): bool { try { return $this->billing->debit( $ownerRef, $amountMinor, $source, $reference, $description, ); } catch (\Throwable $e) { Log::warning('Meet billing debit failed', [ 'owner_ref' => $ownerRef, 'source' => $source, 'reference' => $reference, 'error' => $e->getMessage(), ]); return false; } } protected function deleteUnpaidAsset(Recording|SessionFile $asset): void { if ($asset instanceof Recording) { app(RecordingService::class)->delete($asset, $asset->owner_ref); } else { app(FileSharingService::class)->delete($asset); } } protected function formatGb(int $bytes): string { return number_format($this->storageGb($bytes), 2); } }