diff --git a/app/Services/Meet/LicenseService.php b/app/Services/Meet/LicenseService.php index d6a6711..8d50053 100644 --- a/app/Services/Meet/LicenseService.php +++ b/app/Services/Meet/LicenseService.php @@ -11,19 +11,19 @@ class LicenseService /** @var array> */ public const TIERS = [ 'standard' => [ - 'streaming_hours' => 100, + 'participant_count' => 100, 'recording_storage_bytes' => 5_368_709_120, // 5 GB 'file_storage_bytes' => 5_368_709_120, 'ai_minutes' => 120, ], 'professional' => [ - 'streaming_hours' => 500, + 'participant_count' => 500, 'recording_storage_bytes' => 26_843_545_600, // 25 GB 'file_storage_bytes' => 26_843_545_600, 'ai_minutes' => 600, ], 'enterprise' => [ - 'streaming_hours' => 5000, + 'participant_count' => 5000, 'recording_storage_bytes' => 107_374_182_400, // 100 GB 'file_storage_bytes' => 107_374_182_400, 'ai_minutes' => 5000, diff --git a/app/Services/Meet/MeetBillingService.php b/app/Services/Meet/MeetBillingService.php index 551c31c..a7c4d24 100644 --- a/app/Services/Meet/MeetBillingService.php +++ b/app/Services/Meet/MeetBillingService.php @@ -14,7 +14,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; use RuntimeException; -/** Pay-as-you-go Meet billing: streaming hours + monthly storage per GB (same model as Transfer). */ +/** Pay-as-you-go Meet billing: per-participant sessions + monthly storage per GB. */ class MeetBillingService { private const BYTES_PER_GB = 1073741824; @@ -23,21 +23,11 @@ class MeetBillingService protected BillingClient $billing, ) {} - public function pricePerHourGhs(): float - { - return (float) config('meet.billing.price_per_hour_ghs', 0.30); - } - public function pricePerParticipantGhs(): float { return (float) config('meet.billing.price_per_participant_ghs', 0.30); } - public function usesParticipantBilling(?Room $room): bool - { - return $room?->isWebinar() ?? false; - } - public function pricePerGbMonthGhs(): float { return (float) config('meet.billing.price_per_gb_month_ghs', 0.30); @@ -48,19 +38,6 @@ class MeetBillingService return max(1, (int) config('meet.billing.grace_period_days', 15)); } - public function streamingHoursFor(Session $session): float - { - if (! $session->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) { @@ -70,13 +47,6 @@ class MeetBillingService 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 participantsBillableFor(Session $session): int { return max(1, (int) ($session->peak_participants ?? 0)); @@ -102,21 +72,11 @@ class MeetBillingService public function assertCanStartSession(Organization $organization, ?Room $room = null): void { - if ($this->usesParticipantBilling($room)) { - $amountMinor = $this->amountMinorForParticipants(1); - $message = sprintf( - 'Insufficient wallet balance. Webinars are billed at GHS %.2f per participant — top up your Ladill wallet to start.', - $this->pricePerParticipantGhs(), - ); - } else { - $amountMinor = $this->amountMinorForStreamingHours( - max(1, (int) config('meet.billing.minimum_streaming_hours', 1)) - ); - $message = sprintf( - 'Insufficient wallet balance. Meetings are billed at GHS %.2f per hour — top up your Ladill wallet to start.', - $this->pricePerHourGhs(), - ); - } + $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); @@ -129,41 +89,7 @@ class MeetBillingService public function chargeSession(Session $session): void { - $session->loadMissing('room'); - - if ($this->usesParticipantBilling($session->room)) { - $this->chargeParticipants($session); - - return; - } - - $this->chargeStreaming($session); - } - - 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), - ); + $this->chargeParticipants($session); } public function chargeParticipants(Session $session): void @@ -188,7 +114,7 @@ class MeetBillingService $amountMinor, 'meet_participants', 'meet-participants-'.$session->uuid, - sprintf('Meet webinar: %s (%d participants)', $session->room->title, $count), + sprintf('Meet session: %s (%d participants)', $session->room->title, $count), ); } diff --git a/app/Services/Meet/UsageMeteringService.php b/app/Services/Meet/UsageMeteringService.php index 23008ba..f82581e 100644 --- a/app/Services/Meet/UsageMeteringService.php +++ b/app/Services/Meet/UsageMeteringService.php @@ -47,9 +47,7 @@ class UsageMeteringService $summary = []; foreach ($raw as $metric => $total) { - if ($metric === 'streaming_hours') { - $summary['streaming_hours'] = round(((int) $total) / 100, 2); - } elseif ($metric === 'participant_count') { + if ($metric === 'participant_count') { $summary['participant_count'] = (int) $total; } else { $summary[$metric] = (int) $total; @@ -61,13 +59,11 @@ class UsageMeteringService public function estimatedCostGhs(array $summary): array { - $streamingHours = (float) ($summary['streaming_hours'] ?? 0); $participantCount = (int) ($summary['participant_count'] ?? 0); $recordingBytes = (int) ($summary['recording_storage_bytes'] ?? 0); $fileBytes = (int) ($summary['file_storage_bytes'] ?? 0); return [ - 'streaming' => round($streamingHours * $this->billing->pricePerHourGhs(), 2), 'participants' => round($participantCount * $this->billing->pricePerParticipantGhs(), 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 702623d..16ac940 100644 --- a/config/meet.php +++ b/config/meet.php @@ -172,12 +172,10 @@ return [ ], 'billing' => [ - 'price_per_hour_ghs' => (float) env('MEET_PRICE_PER_HOUR', 0.30), - 'price_per_participant_ghs' => (float) env('MEET_PRICE_PER_PARTICIPANT', 0.30), + 'price_per_participant_ghs' => (float) env('MEET_PRICE_PER_PARTICIPANT', 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/resources/views/meet/admin/usage.blade.php b/resources/views/meet/admin/usage.blade.php index 0df42b5..83f933c 100644 --- a/resources/views/meet/admin/usage.blade.php +++ b/resources/views/meet/admin/usage.blade.php @@ -6,8 +6,7 @@

Pay-as-you-go rates

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

@@ -16,8 +15,7 @@
@php $rows = [ - 'streaming_hours' => ['label' => 'Streaming hours', 'used' => number_format($summary['streaming_hours'] ?? 0, 1), 'cost' => $costs['streaming'] ?? 0], - 'participant_count' => ['label' => 'Webinar participants', 'used' => number_format($summary['participant_count'] ?? 0), 'cost' => $costs['participants'] ?? 0], + 'participant_count' => ['label' => 'Session participants', 'used' => number_format($summary['participant_count'] ?? 0), 'cost' => $costs['participants'] ?? 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], @@ -27,9 +25,9 @@ @foreach ($rows as $metric => $row) @php $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) + $pct = ($metric === 'participant_count' && $limit > 0) + ? min(100, round(((int) ($summary['participant_count'] ?? 0) / $limit) * 100)) + : (($metric !== 'participant_count' && $limit > 0) ? min(100, round(((int) ($summary[$metric] ?? 0) / $limit) * 100)) : 0); @endphp @@ -46,7 +44,7 @@ @if ($limit > 0)
Plan allowance - {{ $metric === 'streaming_hours' ? number_format($limit) : number_format($limit / 1073741824, 0).' GB' }} + {{ $metric === 'participant_count' ? number_format($limit) : number_format($limit / 1073741824, 0).' GB' }}
diff --git a/resources/views/meet/webinars/show.blade.php b/resources/views/meet/webinars/show.blade.php index 2f9e67e..43ac4e1 100644 --- a/resources/views/meet/webinars/show.blade.php +++ b/resources/views/meet/webinars/show.blade.php @@ -21,7 +21,7 @@ @endif
- Conference billing: GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant (peak attendance when the session ends). + Billed at GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant (peak attendance when the session ends).
diff --git a/tests/Feature/MeetBillingTest.php b/tests/Feature/MeetBillingTest.php index e88a75f..4367556 100644 --- a/tests/Feature/MeetBillingTest.php +++ b/tests/Feature/MeetBillingTest.php @@ -25,7 +25,7 @@ class MeetBillingTest extends TestCase ]); } - public function test_session_end_records_streaming_usage_and_debits_wallet(): void + public function test_session_end_charges_per_participant(): void { $this->fakeAffordableBilling(); @@ -53,17 +53,19 @@ class MeetBillingTest extends TestCase 'status' => 'ended', 'started_at' => now()->subHour(), 'ended_at' => now(), + 'peak_participants' => 4, ]); app(UsageMeteringService::class)->recordSessionUsage($session); $this->assertDatabaseHas('meet_usage_records', [ 'organization_id' => $organization->id, - 'metric' => 'streaming_hours', + 'metric' => 'participant_count', + 'quantity' => 4, ]); Http::assertSent(fn ($request) => str_contains($request->url(), '/debit') - && ($request->data()['source'] ?? '') === 'meet_streaming'); + && ($request->data()['source'] ?? '') === 'meet_participants'); } public function test_webinar_session_end_charges_per_participant(): void diff --git a/tests/Unit/MeetBillingServiceTest.php b/tests/Unit/MeetBillingServiceTest.php index a189daa..a253c6c 100644 --- a/tests/Unit/MeetBillingServiceTest.php +++ b/tests/Unit/MeetBillingServiceTest.php @@ -8,28 +8,6 @@ use Tests\TestCase; class MeetBillingServiceTest extends TestCase { - public function test_streaming_hours_are_ceiled_with_minimum_one_hour(): void - { - $billing = app(MeetBillingService::class); - - $session = new Session([ - 'started_at' => 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_participant_amount_minor_matches_rate(): void { config(['meet.billing.price_per_participant_ghs' => 0.30]); @@ -40,6 +18,19 @@ class MeetBillingServiceTest extends TestCase $this->assertSame(90, $billing->amountMinorForParticipants(3)); } + public function test_participants_billable_uses_peak_with_minimum_one(): void + { + $billing = app(MeetBillingService::class); + + $session = new Session(['peak_participants' => 0]); + + $this->assertSame(1, $billing->participantsBillableFor($session)); + + $session->peak_participants = 12; + + $this->assertSame(12, $billing->participantsBillableFor($session)); + } + public function test_storage_amount_minor_matches_gb_monthly_rate(): void { config(['meet.billing.price_per_gb_month_ghs' => 0.30]);