Files
ladill-meet/app/Services/Meet/MeetBillingService.php
T
isaaccladandCursor d19c131428
Deploy Ladill Meet / deploy (push) Successful in 1m35s
Fix join 500, wallet dropdown balance, and launcher Meet entry.
Handle billing API failures gracefully on session start, add wallet.balance JSON endpoint, and sync Meet into the app launcher.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 16:50:02 +00:00

423 lines
13 KiB
PHP

<?php
namespace App\Services\Meet;
use App\Models\Organization;
use App\Models\Recording;
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: streaming hours + monthly storage per GB (same model as Transfer). */
class MeetBillingService
{
private const BYTES_PER_GB = 1073741824;
public function __construct(
protected BillingClient $billing,
) {}
public function pricePerHourGhs(): float
{
return (float) config('meet.billing.price_per_hour_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 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) {
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))
);
$affordable = $this->canAffordSafely($organization->owner_ref, $amountMinor);
if ($affordable === null) {
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(),
),
);
}
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->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);
}
}