Separate webinars from meetings and add conference billing.
Deploy Ladill Meet / deploy (push) Successful in 41s
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user