Files
ladill-meet/app/Services/Meet/MeetBillingService.php
T
isaaccladandCursor 5c1affcca5
Deploy Ladill Meet / deploy (push) Successful in 46s
Bill all meet sessions per participant instead of per hour.
Meetings and webinars now share GHS 0.30 per peak participant, with usage UI and plan quotas updated to match.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 18:28:35 +00:00

416 lines
12 KiB
PHP

<?php
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;
use App\Services\Billing\BillingClient;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use RuntimeException;
/** Pay-as-you-go Meet billing: per-participant sessions + monthly storage per GB. */
class MeetBillingService
{
private const BYTES_PER_GB = 1073741824;
public function __construct(
protected BillingClient $billing,
) {}
public function pricePerParticipantGhs(): float
{
return (float) config('meet.billing.price_per_participant_ghs', 0.30);
}
public function pricePerGbMonthGhs(): float
{
return (float) config('meet.billing.price_per_gb_month_ghs', 0.30);
}
public function gracePeriodDays(): int
{
return max(1, (int) config('meet.billing.grace_period_days', 15));
}
public function storageGb(int $bytes): float
{
if ($bytes <= 0) {
return 0;
}
return round($bytes / self::BYTES_PER_GB, 4);
}
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) {
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);
}
}