Deploy Ladill Meet / deploy (push) Successful in 1m35s
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>
423 lines
13 KiB
PHP
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);
|
|
}
|
|
}
|