Separate webinars from meetings and add conference billing.
Deploy Ladill Meet / deploy (push) Successful in 41s

Add restart meeting actions, a dedicated webinar sidebar flow billed at GHS 0.30 per participant, and reserve speaker-line space with a placeholder to prevent layout shift.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 18:23:04 +00:00
co-authored by Cursor
parent 3585904618
commit d0a3361f37
24 changed files with 598 additions and 51 deletions
+3 -2
View File
@@ -3,6 +3,7 @@
namespace App\Services\Meet;
use App\Models\Organization;
use App\Models\Room;
use App\Models\UsageRecord;
class LicenseService
@@ -59,8 +60,8 @@ class LicenseService
return ($used + $additional) <= $limit;
}
public function assertCanStartSession(Organization $organization, ?int $branchId = null): void
public function assertCanStartSession(Organization $organization, ?int $branchId = null, ?Room $room = null): void
{
app(MeetBillingService::class)->assertCanStartSession($organization);
app(MeetBillingService::class)->assertCanStartSession($organization, $room);
}
}
+79 -12
View File
@@ -4,6 +4,7 @@ namespace App\Services\Meet;
use App\Models\Organization;
use App\Models\Recording;
use App\Models\Room;
use App\Models\Session;
use App\Models\SessionFile;
use App\Models\UsageRecord;
@@ -27,6 +28,16 @@ class MeetBillingService
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);
@@ -66,6 +77,18 @@ class MeetBillingService
return max((int) round($ghs * 100), 1);
}
public function participantsBillableFor(Session $session): int
{
return max(1, (int) ($session->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) {
@@ -77,11 +100,23 @@ class MeetBillingService
return max((int) round($ghs * 100), 1);
}
public function assertCanStartSession(Organization $organization): void
public function assertCanStartSession(Organization $organization, ?Room $room = null): void
{
$amountMinor = $this->amountMinorForStreamingHours(
max(1, (int) config('meet.billing.minimum_streaming_hours', 1))
);
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(),
);
}
$affordable = $this->canAffordSafely($organization->owner_ref, $amountMinor);
@@ -89,14 +124,20 @@ class MeetBillingService
abort(503, 'Billing is temporarily unavailable. Please try again shortly.');
}
abort_unless(
$affordable,
402,
sprintf(
'Insufficient wallet balance. Meetings are billed at GHS %.2f per hour — top up your Ladill wallet to start.',
$this->pricePerHourGhs(),
),
);
abort_unless($affordable, 402, $message);
}
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
@@ -125,6 +166,32 @@ class MeetBillingService
);
}
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 webinar: %s (%d participants)', $session->room->title, $count),
);
}
public function chargeRecordingStorageInitial(Recording $recording): void
{
$recording->loadMissing('session.room.organization');
+1 -1
View File
@@ -24,7 +24,7 @@ class SessionService
return $existing;
}
app(LicenseService::class)->assertCanStartSession($room->organization, $room->branch_id);
app(LicenseService::class)->assertCanStartSession($room->organization, $room->branch_id, $room);
$session = Session::create([
'owner_ref' => $room->owner_ref,
+5 -1
View File
@@ -14,7 +14,7 @@ class UsageMeteringService
public function recordSessionUsage(Session $session): void
{
$this->billing->chargeStreaming($session);
$this->billing->chargeSession($session);
}
public function recordAiMinutes(Organization $organization, int $minutes, string $sessionUuid): void
@@ -49,6 +49,8 @@ class UsageMeteringService
foreach ($raw as $metric => $total) {
if ($metric === 'streaming_hours') {
$summary['streaming_hours'] = round(((int) $total) / 100, 2);
} elseif ($metric === 'participant_count') {
$summary['participant_count'] = (int) $total;
} else {
$summary[$metric] = (int) $total;
}
@@ -60,11 +62,13 @@ 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),
];