From 59b59cb70e851e4da7c7eea7d840aeec27dcce49 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 1 Jul 2026 16:33:45 +0000 Subject: [PATCH] Bill Meet streaming hourly and storage per GB like Transfer. Wallet debits at session end, on recording/file storage, and via daily renewals with grace period before asset deletion. Co-authored-by: Cursor --- .env.example | 4 + .../Commands/ProcessMeetBillingCommand.php | 27 ++ app/Http/Controllers/Meet/AdminController.php | 4 +- .../Controllers/Meet/RecordingController.php | 1 + app/Models/Recording.php | 4 + app/Models/SessionFile.php | 8 +- app/Services/Meet/FileSharingService.php | 20 +- app/Services/Meet/LicenseService.php | 15 +- app/Services/Meet/MeetBillingService.php | 400 ++++++++++++++++++ app/Services/Meet/RecordingService.php | 2 + app/Services/Meet/UsageMeteringService.php | 93 +--- config/meet.php | 9 +- ...000_add_storage_billing_to_meet_assets.php | 34 ++ resources/views/meet/admin/usage.blade.php | 53 ++- routes/console.php | 1 + tests/Feature/MeetBillingTest.php | 120 ++++++ tests/Feature/MeetWebTest.php | 8 + tests/Unit/MeetBillingServiceTest.php | 42 ++ 18 files changed, 749 insertions(+), 96 deletions(-) create mode 100644 app/Console/Commands/ProcessMeetBillingCommand.php create mode 100644 app/Services/Meet/MeetBillingService.php create mode 100644 database/migrations/2026_07_01_140000_add_storage_billing_to_meet_assets.php create mode 100644 tests/Feature/MeetBillingTest.php create mode 100644 tests/Unit/MeetBillingServiceTest.php diff --git a/.env.example b/.env.example index f515fb3..1b6dc2e 100644 --- a/.env.example +++ b/.env.example @@ -68,5 +68,9 @@ MICROSOFT_CALENDAR_CLIENT_SECRET= MICROSOFT_CALENDAR_REDIRECT_URI=https://meet.ladill.com/settings/calendar/callback MEET_FILES_DISK=local MEET_FILES_EXPIRY_DAYS=30 +MEET_PRICE_PER_HOUR=0.30 +MEET_PRICE_PER_GB_MONTH=0.30 +MEET_BILLING_PERIOD_DAYS=30 +MEET_GRACE_PERIOD_DAYS=15 LADILL_DRIVE_API_URL= LADILL_DRIVE_API_KEY_MEET= diff --git a/app/Console/Commands/ProcessMeetBillingCommand.php b/app/Console/Commands/ProcessMeetBillingCommand.php new file mode 100644 index 0000000..20d6516 --- /dev/null +++ b/app/Console/Commands/ProcessMeetBillingCommand.php @@ -0,0 +1,27 @@ +processDueStorageRenewals(); + + $this->info(sprintf( + 'Meet storage billing: %d renewed, %d entered grace, %d deleted.', + $result['renewed'], + $result['graced'], + $result['deleted'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Meet/AdminController.php b/app/Http/Controllers/Meet/AdminController.php index 46fdd2c..e814c01 100644 --- a/app/Http/Controllers/Meet/AdminController.php +++ b/app/Http/Controllers/Meet/AdminController.php @@ -27,9 +27,11 @@ class AdminController extends Controller $branchId = app(OrganizationResolver::class)->branchScope($this->member($request)); $summary = $this->metering->usageSummary($organization, $branchId); + $costs = $this->metering->estimatedCostGhs($summary); $quotas = $this->licenses->quotas($organization); $tier = $organization->license_tier ?? 'standard'; + $billing = app(\App\Services\Meet\MeetBillingService::class); - return view('meet.admin.usage', compact('organization', 'summary', 'quotas', 'tier')); + return view('meet.admin.usage', compact('organization', 'summary', 'costs', 'quotas', 'tier', 'billing')); } } diff --git a/app/Http/Controllers/Meet/RecordingController.php b/app/Http/Controllers/Meet/RecordingController.php index 2c4e031..3e9b6f0 100644 --- a/app/Http/Controllers/Meet/RecordingController.php +++ b/app/Http/Controllers/Meet/RecordingController.php @@ -49,6 +49,7 @@ class RecordingController extends Controller $this->authorizeAbility($request, 'meetings.view'); $this->authorizeOwner($request, $recording); abort_unless($recording->isReady() && $recording->storage_path, 404); + abort_unless(app(\App\Services\Meet\MeetBillingService::class)->storageIsAccessible($recording), 402, 'Recording storage billing is overdue. Top up your Ladill wallet to download this recording.'); return Storage::disk(config('meet.recordings.disk', 'local')) ->download($recording->storage_path, $recording->session->room->title.'.mp4'); diff --git a/app/Models/Recording.php b/app/Models/Recording.php index d926ec4..4e6ac0b 100644 --- a/app/Models/Recording.php +++ b/app/Models/Recording.php @@ -17,6 +17,7 @@ class Recording extends Model 'uuid', 'owner_ref', 'session_id', 'status', 'layout', 'storage_path', 'thumbnail_path', 'file_size', 'duration_seconds', 'started_by_ref', 'started_at', 'ended_at', 'failure_reason', 'metadata', + 'storage_paid_until', 'storage_grace_ends_at', 'storage_last_billed_at', ]; protected function casts(): array @@ -24,6 +25,9 @@ class Recording extends Model return [ 'started_at' => 'datetime', 'ended_at' => 'datetime', + 'storage_paid_until' => 'datetime', + 'storage_grace_ends_at' => 'datetime', + 'storage_last_billed_at' => 'datetime', 'metadata' => 'array', ]; } diff --git a/app/Models/SessionFile.php b/app/Models/SessionFile.php index c3fc22e..267714a 100644 --- a/app/Models/SessionFile.php +++ b/app/Models/SessionFile.php @@ -17,11 +17,17 @@ class SessionFile extends Model protected $fillable = [ 'uuid', 'owner_ref', 'room_id', 'session_id', 'uploaded_by_ref', 'uploaded_by_name', 'original_name', 'storage_path', 'mime_type', 'file_size', 'drive_file_id', 'expires_at', + 'storage_paid_until', 'storage_grace_ends_at', 'storage_last_billed_at', ]; protected function casts(): array { - return ['expires_at' => 'datetime']; + return [ + 'expires_at' => 'datetime', + 'storage_paid_until' => 'datetime', + 'storage_grace_ends_at' => 'datetime', + 'storage_last_billed_at' => 'datetime', + ]; } protected static function booted(): void diff --git a/app/Services/Meet/FileSharingService.php b/app/Services/Meet/FileSharingService.php index e02ab76..94a8b76 100644 --- a/app/Services/Meet/FileSharingService.php +++ b/app/Services/Meet/FileSharingService.php @@ -23,11 +23,16 @@ class FileSharingService ?int $expiresInDays = null, ): SessionFile { $expiresInDays ??= config('meet.files.default_expiry_days'); + $room->loadMissing('organization'); + + $bytes = (int) $file->getSize(); + $billing = app(MeetBillingService::class); + $disk = Storage::disk(config('meet.files.disk', 'local')); $path = 'meet-files/'.$room->uuid.'/'.Str::uuid().'_'.$file->getClientOriginalName(); $disk->put($path, $file->get()); - return SessionFile::create([ + $sessionFile = SessionFile::create([ 'owner_ref' => $room->owner_ref, 'room_id' => $room->id, 'session_id' => $session?->id, @@ -36,9 +41,19 @@ class FileSharingService 'original_name' => $file->getClientOriginalName(), 'storage_path' => $path, 'mime_type' => $file->getMimeType(), - 'file_size' => $file->getSize(), + 'file_size' => $bytes, 'expires_at' => $expiresInDays ? now()->addDays($expiresInDays) : null, ]); + + try { + $billing->chargeFileStorageInitial($sessionFile); + } catch (\Throwable $e) { + $disk->delete($path); + $sessionFile->delete(); + throw $e; + } + + return $sessionFile; } public function linkFromDrive(Room $room, string $driveFileId, string $name, string $uploadedByRef, string $uploadedByName): SessionFile @@ -58,6 +73,7 @@ class FileSharingService public function download(SessionFile $file, ?Participant $participant = null): string { abort_if($file->isExpired(), 410, 'File has expired.'); + abort_unless(app(MeetBillingService::class)->storageIsAccessible($file), 402, 'File storage billing is overdue. Top up your Ladill wallet to access this file.'); FileDownload::create([ 'session_file_id' => $file->id, diff --git a/app/Services/Meet/LicenseService.php b/app/Services/Meet/LicenseService.php index 388e450..e08f41a 100644 --- a/app/Services/Meet/LicenseService.php +++ b/app/Services/Meet/LicenseService.php @@ -10,18 +10,21 @@ class LicenseService /** @var array> */ public const TIERS = [ 'standard' => [ - 'participant_minutes' => 10000, + 'streaming_hours' => 100, 'recording_storage_bytes' => 5_368_709_120, // 5 GB + 'file_storage_bytes' => 5_368_709_120, 'ai_minutes' => 120, ], 'professional' => [ - 'participant_minutes' => 50000, + 'streaming_hours' => 500, 'recording_storage_bytes' => 26_843_545_600, // 25 GB + 'file_storage_bytes' => 26_843_545_600, 'ai_minutes' => 600, ], 'enterprise' => [ - 'participant_minutes' => 500000, + 'streaming_hours' => 5000, 'recording_storage_bytes' => 107_374_182_400, // 100 GB + 'file_storage_bytes' => 107_374_182_400, 'ai_minutes' => 5000, ], ]; @@ -58,10 +61,6 @@ class LicenseService public function assertCanStartSession(Organization $organization, ?int $branchId = null): void { - abort_unless( - $this->withinQuota($organization, 'participant_minutes', 60, $branchId), - 402, - 'Participant-minute quota exceeded for this billing period.', - ); + app(MeetBillingService::class)->assertCanStartSession($organization); } } diff --git a/app/Services/Meet/MeetBillingService.php b/app/Services/Meet/MeetBillingService.php new file mode 100644 index 0000000..76f7b63 --- /dev/null +++ b/app/Services/Meet/MeetBillingService.php @@ -0,0 +1,400 @@ +started_at) { + return 0; + } + + $ended = $session->ended_at ?? now(); + $hours = $session->started_at->diffInSeconds($ended) / 3600; + $minimum = max(1, (int) config('meet.billing.minimum_streaming_hours', 1)); + + return max($minimum, ceil($hours)); + } + + public function storageGb(int $bytes): float + { + if ($bytes <= 0) { + return 0; + } + + return round($bytes / self::BYTES_PER_GB, 4); + } + + public function amountMinorForStreamingHours(float $hours): int + { + $ghs = round($hours * $this->pricePerHourGhs(), 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): void + { + $amountMinor = $this->amountMinorForStreamingHours( + max(1, (int) config('meet.billing.minimum_streaming_hours', 1)) + ); + + abort_unless( + $this->billing->canAfford($organization->owner_ref, $amountMinor), + 402, + sprintf( + 'Insufficient wallet balance. Meetings are billed at GHS %.2f per hour — top up your Ladill wallet to start.', + $this->pricePerHourGhs(), + ), + ); + } + + public function chargeStreaming(Session $session): void + { + $session->loadMissing('room.organization'); + $organization = $session->room->organization; + $hours = $this->streamingHoursFor($session); + $amountMinor = $this->amountMinorForStreamingHours($hours); + + if ($amountMinor <= 0) { + return; + } + + $this->recordUsage($organization, 'streaming_hours', (int) round($hours * 100), [ + 'session_uuid' => $session->uuid, + 'room_uuid' => $session->room->uuid, + 'hours' => $hours, + ], $session->room->branch_id); + + $this->debit( + $organization->owner_ref, + $amountMinor, + 'meet_streaming', + 'meet-streaming-'.$session->uuid, + sprintf('Meet streaming: %s (%.1f hr)', $session->room->title, $hours), + ); + } + + 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->billing->canAfford($organization->owner_ref, $amountMinor)) { + 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->billing->canAfford($organization->owner_ref, $amountMinor)) { + 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(), + ]); + } + + 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); + } +} diff --git a/app/Services/Meet/RecordingService.php b/app/Services/Meet/RecordingService.php index 81a5ca2..50e6fcf 100644 --- a/app/Services/Meet/RecordingService.php +++ b/app/Services/Meet/RecordingService.php @@ -97,6 +97,8 @@ class RecordingService app(\App\Services\Integrations\WebhookDispatcher::class)->recordingReady($recording); + app(MeetBillingService::class)->chargeRecordingStorageInitial($recording->fresh()); + if ($session->room->setting('auto_ai_summary', false) || config('meet.ai.auto_summarize', true)) { app(TranscriptService::class)->ensureForSession($session, $recording); } diff --git a/app/Services/Meet/UsageMeteringService.php b/app/Services/Meet/UsageMeteringService.php index d6ba958..7449a5a 100644 --- a/app/Services/Meet/UsageMeteringService.php +++ b/app/Services/Meet/UsageMeteringService.php @@ -5,45 +5,16 @@ namespace App\Services\Meet; use App\Models\Organization; use App\Models\Session; use App\Models\UsageRecord; -use App\Services\Billing\BillingClient; -use Illuminate\Support\Facades\Log; class UsageMeteringService { public function __construct( - protected BillingClient $billing, - protected LicenseService $licenses, + protected MeetBillingService $billing, ) {} public function recordSessionUsage(Session $session): void { - $room = $session->room; - $organization = $room->organization; - $minutes = $this->participantMinutes($session); - - UsageRecord::create([ - 'owner_ref' => $session->owner_ref, - 'organization_id' => $organization->id, - 'branch_id' => $room->branch_id, - 'metric' => 'participant_minutes', - 'quantity' => $minutes, - 'metadata' => ['session_uuid' => $session->uuid, 'room_uuid' => $room->uuid], - 'recorded_at' => now(), - ]); - - $this->maybeDebit($organization, $minutes, 'participant_minutes', $session->uuid); - } - - public function recordRecordingStorage(Organization $organization, int $bytes, string $recordingUuid): void - { - UsageRecord::create([ - 'owner_ref' => $organization->owner_ref, - 'organization_id' => $organization->id, - 'metric' => 'recording_storage_bytes', - 'quantity' => $bytes, - 'metadata' => ['recording_uuid' => $recordingUuid], - 'recorded_at' => now(), - ]); + $this->billing->chargeStreaming($session); } public function recordAiMinutes(Organization $organization, int $minutes, string $sessionUuid): void @@ -56,8 +27,6 @@ class UsageMeteringService 'metadata' => ['session_uuid' => $sessionUuid], 'recorded_at' => now(), ]); - - $this->maybeDebit($organization, $minutes, 'ai_minutes', $sessionUuid); } public function usageSummary(Organization $organization, ?int $branchId = null, int $days = 30): array @@ -70,50 +39,34 @@ class UsageMeteringService $query->where('branch_id', $branchId); } - return $query + $raw = $query ->selectRaw('metric, SUM(quantity) as total') ->groupBy('metric') ->pluck('total', 'metric') ->all(); + + $summary = []; + foreach ($raw as $metric => $total) { + if ($metric === 'streaming_hours') { + $summary['streaming_hours'] = round(((int) $total) / 100, 2); + } else { + $summary[$metric] = (int) $total; + } + } + + return $summary; } - protected function participantMinutes(Session $session): int + public function estimatedCostGhs(array $summary): array { - if (! $session->started_at) { - return 0; - } + $streamingHours = (float) ($summary['streaming_hours'] ?? 0); + $recordingBytes = (int) ($summary['recording_storage_bytes'] ?? 0); + $fileBytes = (int) ($summary['file_storage_bytes'] ?? 0); - $ended = $session->ended_at ?? now(); - $durationMinutes = max(1, (int) ceil($session->started_at->diffInSeconds($ended) / 60)); - $participants = max(1, $session->participants()->count()); - - return $durationMinutes * $participants; - } - - protected function maybeDebit(Organization $organization, int $quantity, string $metric, string $reference): void - { - $rates = config('meet.billing.rates', []); - $rateMinor = (int) ($rates[$metric] ?? 0); - if ($rateMinor <= 0) { - return; - } - - $amountMinor = $quantity * $rateMinor; - - try { - $this->billing->debit( - $organization->owner_ref, - $amountMinor, - 'meet_'.$metric, - 'meet-usage-'.$reference, - 'Ladill Meet '.$metric, - ); - } catch (\Throwable $e) { - Log::warning('Meet usage billing debit failed', [ - 'organization' => $organization->id, - 'metric' => $metric, - 'error' => $e->getMessage(), - ]); - } + return [ + 'streaming' => round($streamingHours * $this->billing->pricePerHourGhs(), 2), + 'recording_storage' => round($this->billing->storageGb($recordingBytes) * $this->billing->pricePerGbMonthGhs(), 2), + 'file_storage' => round($this->billing->storageGb($fileBytes) * $this->billing->pricePerGbMonthGhs(), 2), + ]; } } diff --git a/config/meet.php b/config/meet.php index 603b12a..c5d25ed 100644 --- a/config/meet.php +++ b/config/meet.php @@ -171,10 +171,11 @@ return [ ], 'billing' => [ - 'rates' => [ - 'participant_minutes' => (int) env('MEET_RATE_PARTICIPANT_MINUTE_MINOR', 0), - 'ai_minutes' => (int) env('MEET_RATE_AI_MINUTE_MINOR', 0), - ], + 'price_per_hour_ghs' => (float) env('MEET_PRICE_PER_HOUR', 0.30), + 'price_per_gb_month_ghs' => (float) env('MEET_PRICE_PER_GB_MONTH', 0.30), + 'billing_period_days' => (int) env('MEET_BILLING_PERIOD_DAYS', 30), + 'grace_period_days' => (int) env('MEET_GRACE_PERIOD_DAYS', 15), + 'minimum_streaming_hours' => (int) env('MEET_MIN_STREAMING_HOURS', 1), ], 'audit_actions' => [ diff --git a/database/migrations/2026_07_01_140000_add_storage_billing_to_meet_assets.php b/database/migrations/2026_07_01_140000_add_storage_billing_to_meet_assets.php new file mode 100644 index 0000000..e1b9961 --- /dev/null +++ b/database/migrations/2026_07_01_140000_add_storage_billing_to_meet_assets.php @@ -0,0 +1,34 @@ +timestamp('storage_paid_until')->nullable()->after('file_size'); + $table->timestamp('storage_grace_ends_at')->nullable()->after('storage_paid_until'); + $table->timestamp('storage_last_billed_at')->nullable()->after('storage_grace_ends_at'); + }); + + Schema::table('meet_session_files', function (Blueprint $table) { + $table->timestamp('storage_paid_until')->nullable()->after('file_size'); + $table->timestamp('storage_grace_ends_at')->nullable()->after('storage_paid_until'); + $table->timestamp('storage_last_billed_at')->nullable()->after('storage_grace_ends_at'); + }); + } + + public function down(): void + { + Schema::table('meet_recordings', function (Blueprint $table) { + $table->dropColumn(['storage_paid_until', 'storage_grace_ends_at', 'storage_last_billed_at']); + }); + + Schema::table('meet_session_files', function (Blueprint $table) { + $table->dropColumn(['storage_paid_until', 'storage_grace_ends_at', 'storage_last_billed_at']); + }); + } +}; diff --git a/resources/views/meet/admin/usage.blade.php b/resources/views/meet/admin/usage.blade.php index ab34d6e..008c83c 100644 --- a/resources/views/meet/admin/usage.blade.php +++ b/resources/views/meet/admin/usage.blade.php @@ -1,22 +1,55 @@
-

Usage & quotas

+

Usage & billing

{{ $organization->name }} · {{ ucfirst($tier) }} plan

+
+

Pay-as-you-go rates

+
    +
  • Live meetings: GHS {{ number_format($billing->pricePerHourGhs(), 2) }} / hour (billed when a session ends)
  • +
  • Recordings & shared files: GHS {{ number_format($billing->pricePerGbMonthGhs(), 2) }} / GB / month (same as Ladill Transfer)
  • +
+

Charges debit your Ladill wallet. Storage renews monthly; unpaid items enter a {{ $billing->gracePeriodDays() }}-day grace period before deletion.

+
+
- @foreach ($quotas as $metric => $limit) + @php + $rows = [ + 'streaming_hours' => ['label' => 'Streaming hours', 'used' => number_format($summary['streaming_hours'] ?? 0, 1), 'cost' => $costs['streaming'] ?? 0], + 'recording_storage_bytes' => ['label' => 'Recording storage', 'used' => number_format(($summary['recording_storage_bytes'] ?? 0) / 1073741824, 2).' GB', 'cost' => $costs['recording_storage'] ?? 0], + 'file_storage_bytes' => ['label' => 'Shared file storage', 'used' => number_format(($summary['file_storage_bytes'] ?? 0) / 1073741824, 2).' GB', 'cost' => $costs['file_storage'] ?? 0], + 'ai_minutes' => ['label' => 'AI minutes', 'used' => number_format($summary['ai_minutes'] ?? 0), 'cost' => null], + ]; + @endphp + + @foreach ($rows as $metric => $row) @php - $used = (int) ($summary[$metric] ?? 0); - $pct = $limit > 0 ? min(100, round(($used / $limit) * 100)) : 0; + $limit = (int) ($quotas[$metric] ?? 0); + $pct = ($metric === 'streaming_hours' && $limit > 0) + ? min(100, round((($summary['streaming_hours'] ?? 0) / $limit) * 100)) + : (($metric !== 'streaming_hours' && $limit > 0) + ? min(100, round(((int) ($summary[$metric] ?? 0) / $limit) * 100)) + : 0); @endphp
-
-

{{ str_replace('_', ' ', ucfirst($metric)) }}

- {{ number_format($used) }} / {{ number_format($limit) }} -
-
-
+
+
+

{{ $row['label'] }}

+

Last 30 days: {{ $row['used'] }}

+
+ @if ($row['cost'] !== null) + GHS {{ number_format($row['cost'], 2) }} + @endif
+ @if ($limit > 0) +
+ Plan allowance + {{ $metric === 'streaming_hours' ? number_format($limit) : number_format($limit / 1073741824, 0).' GB' }} +
+
+
+
+ @endif
@endforeach
diff --git a/routes/console.php b/routes/console.php index 0cc9cfd..f55901f 100644 --- a/routes/console.php +++ b/routes/console.php @@ -3,3 +3,4 @@ use Illuminate\Support\Facades\Schedule; Schedule::command('meet:send-reminders')->everyFiveMinutes(); +Schedule::command('meet:process-billing')->daily()->withoutOverlapping(); diff --git a/tests/Feature/MeetBillingTest.php b/tests/Feature/MeetBillingTest.php new file mode 100644 index 0000000..51c76bc --- /dev/null +++ b/tests/Feature/MeetBillingTest.php @@ -0,0 +1,120 @@ + Http::response(['affordable' => true]), + $base.'/debit' => Http::response(['ok' => true]), + ]); + } + + public function test_session_end_records_streaming_usage_and_debits_wallet(): void + { + $this->fakeAffordableBilling(); + + $organization = Organization::create([ + 'owner_ref' => 'owner-001', + 'name' => 'Billing Org', + 'slug' => 'billing-org', + 'timezone' => 'UTC', + ]); + + $room = Room::create([ + 'owner_ref' => 'owner-001', + 'organization_id' => $organization->id, + 'host_user_ref' => 'owner-001', + 'title' => 'Billing Room', + 'type' => 'instant', + 'status' => 'live', + 'settings' => config('meet.default_settings'), + ]); + + $session = Session::create([ + 'owner_ref' => 'owner-001', + 'room_id' => $room->id, + 'media_room_name' => 'room-test', + 'status' => 'ended', + 'started_at' => now()->subHour(), + 'ended_at' => now(), + ]); + + app(UsageMeteringService::class)->recordSessionUsage($session); + + $this->assertDatabaseHas('meet_usage_records', [ + 'organization_id' => $organization->id, + 'metric' => 'streaming_hours', + ]); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/debit') + && ($request->data()['source'] ?? '') === 'meet_streaming'); + } + + public function test_storage_renewal_enters_grace_when_wallet_is_empty(): void + { + $base = rtrim((string) config('billing.api_url'), '/'); + Http::fake([ + $base.'/can-afford*' => Http::response(['affordable' => false]), + ]); + + $organization = Organization::create([ + 'owner_ref' => 'owner-002', + 'name' => 'Grace Org', + 'slug' => 'grace-org', + 'timezone' => 'UTC', + ]); + + $room = Room::create([ + 'owner_ref' => 'owner-002', + 'organization_id' => $organization->id, + 'host_user_ref' => 'owner-002', + 'title' => 'Grace Room', + 'type' => 'instant', + 'status' => 'ended', + 'settings' => config('meet.default_settings'), + ]); + + $session = Session::create([ + 'owner_ref' => 'owner-002', + 'room_id' => $room->id, + 'media_room_name' => 'room-grace', + 'status' => 'ended', + 'started_at' => now()->subDay(), + 'ended_at' => now()->subDay(), + ]); + + $file = \App\Models\SessionFile::create([ + 'owner_ref' => 'owner-002', + 'room_id' => $room->id, + 'session_id' => $session->id, + 'uploaded_by_ref' => 'owner-002', + 'uploaded_by_name' => 'Host', + 'original_name' => 'notes.pdf', + 'storage_path' => 'meet-files/test/notes.pdf', + 'file_size' => 1073741824, + 'storage_paid_until' => now()->subMinute(), + ]); + + $result = app(MeetBillingService::class)->processDueStorageRenewals(); + + $file->refresh(); + $this->assertSame(1, $result['graced']); + $this->assertNotNull($file->storage_grace_ends_at); + } +} diff --git a/tests/Feature/MeetWebTest.php b/tests/Feature/MeetWebTest.php index 655b04b..6948c7c 100644 --- a/tests/Feature/MeetWebTest.php +++ b/tests/Feature/MeetWebTest.php @@ -10,6 +10,7 @@ use App\Models\Room; use App\Models\Session; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; use Tests\TestCase; class MeetWebTest extends TestCase @@ -45,6 +46,13 @@ class MeetWebTest extends TestCase 'user_ref' => $this->user->public_id, 'role' => 'owner', ]); + + $base = rtrim((string) config('billing.api_url'), '/'); + Http::fake([ + $base.'/can-afford*' => Http::response(['affordable' => true]), + $base.'/debit' => Http::response(['ok' => true]), + $base.'/balance*' => Http::response(['balance_minor' => 100000]), + ]); } public function test_health_endpoint(): void diff --git a/tests/Unit/MeetBillingServiceTest.php b/tests/Unit/MeetBillingServiceTest.php new file mode 100644 index 0000000..9bf4545 --- /dev/null +++ b/tests/Unit/MeetBillingServiceTest.php @@ -0,0 +1,42 @@ + now()->subMinutes(10), + 'ended_at' => now(), + ]); + + $this->assertSame(1.0, $billing->streamingHoursFor($session)); + } + + public function test_streaming_amount_minor_matches_hourly_rate(): void + { + config(['meet.billing.price_per_hour_ghs' => 0.30]); + + $billing = app(MeetBillingService::class); + + $this->assertSame(30, $billing->amountMinorForStreamingHours(1)); + $this->assertSame(60, $billing->amountMinorForStreamingHours(2)); + } + + public function test_storage_amount_minor_matches_gb_monthly_rate(): void + { + config(['meet.billing.price_per_gb_month_ghs' => 0.30]); + + $billing = app(MeetBillingService::class); + $oneGb = 1073741824; + + $this->assertSame(30, $billing->amountMinorForStorageBytes($oneGb)); + } +}