Bill Meet streaming hourly and storage per GB like Transfer.
Deploy Ladill Meet / deploy (push) Successful in 54s

Wallet debits at session end, on recording/file storage, and via daily renewals with grace period before asset deletion.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 16:33:45 +00:00
co-authored by Cursor
parent 13a251250c
commit 59b59cb70e
18 changed files with 749 additions and 96 deletions
+18 -2
View File
@@ -23,11 +23,16 @@ class FileSharingService
?int $expiresInDays = null,
): SessionFile {
$expiresInDays ??= config('meet.files.default_expiry_days');
$room->loadMissing('organization');
$bytes = (int) $file->getSize();
$billing = app(MeetBillingService::class);
$disk = Storage::disk(config('meet.files.disk', 'local'));
$path = 'meet-files/'.$room->uuid.'/'.Str::uuid().'_'.$file->getClientOriginalName();
$disk->put($path, $file->get());
return SessionFile::create([
$sessionFile = SessionFile::create([
'owner_ref' => $room->owner_ref,
'room_id' => $room->id,
'session_id' => $session?->id,
@@ -36,9 +41,19 @@ class FileSharingService
'original_name' => $file->getClientOriginalName(),
'storage_path' => $path,
'mime_type' => $file->getMimeType(),
'file_size' => $file->getSize(),
'file_size' => $bytes,
'expires_at' => $expiresInDays ? now()->addDays($expiresInDays) : null,
]);
try {
$billing->chargeFileStorageInitial($sessionFile);
} catch (\Throwable $e) {
$disk->delete($path);
$sessionFile->delete();
throw $e;
}
return $sessionFile;
}
public function linkFromDrive(Room $room, string $driveFileId, string $name, string $uploadedByRef, string $uploadedByName): SessionFile
@@ -58,6 +73,7 @@ class FileSharingService
public function download(SessionFile $file, ?Participant $participant = null): string
{
abort_if($file->isExpired(), 410, 'File has expired.');
abort_unless(app(MeetBillingService::class)->storageIsAccessible($file), 402, 'File storage billing is overdue. Top up your Ladill wallet to access this file.');
FileDownload::create([
'session_file_id' => $file->id,
+7 -8
View File
@@ -10,18 +10,21 @@ class LicenseService
/** @var array<string, array<string, int>> */
public const TIERS = [
'standard' => [
'participant_minutes' => 10000,
'streaming_hours' => 100,
'recording_storage_bytes' => 5_368_709_120, // 5 GB
'file_storage_bytes' => 5_368_709_120,
'ai_minutes' => 120,
],
'professional' => [
'participant_minutes' => 50000,
'streaming_hours' => 500,
'recording_storage_bytes' => 26_843_545_600, // 25 GB
'file_storage_bytes' => 26_843_545_600,
'ai_minutes' => 600,
],
'enterprise' => [
'participant_minutes' => 500000,
'streaming_hours' => 5000,
'recording_storage_bytes' => 107_374_182_400, // 100 GB
'file_storage_bytes' => 107_374_182_400,
'ai_minutes' => 5000,
],
];
@@ -58,10 +61,6 @@ class LicenseService
public function assertCanStartSession(Organization $organization, ?int $branchId = null): void
{
abort_unless(
$this->withinQuota($organization, 'participant_minutes', 60, $branchId),
402,
'Participant-minute quota exceeded for this billing period.',
);
app(MeetBillingService::class)->assertCanStartSession($organization);
}
}
+400
View File
@@ -0,0 +1,400 @@
<?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))
);
abort_unless(
$this->billing->canAfford($organization->owner_ref, $amountMinor),
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->billing->canAfford($organization->owner_ref, $amountMinor)) {
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->billing->canAfford($organization->owner_ref, $amountMinor)) {
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(),
]);
}
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);
}
}
+2
View File
@@ -97,6 +97,8 @@ class RecordingService
app(\App\Services\Integrations\WebhookDispatcher::class)->recordingReady($recording);
app(MeetBillingService::class)->chargeRecordingStorageInitial($recording->fresh());
if ($session->room->setting('auto_ai_summary', false) || config('meet.ai.auto_summarize', true)) {
app(TranscriptService::class)->ensureForSession($session, $recording);
}
+23 -70
View File
@@ -5,45 +5,16 @@ namespace App\Services\Meet;
use App\Models\Organization;
use App\Models\Session;
use App\Models\UsageRecord;
use App\Services\Billing\BillingClient;
use Illuminate\Support\Facades\Log;
class UsageMeteringService
{
public function __construct(
protected BillingClient $billing,
protected LicenseService $licenses,
protected MeetBillingService $billing,
) {}
public function recordSessionUsage(Session $session): void
{
$room = $session->room;
$organization = $room->organization;
$minutes = $this->participantMinutes($session);
UsageRecord::create([
'owner_ref' => $session->owner_ref,
'organization_id' => $organization->id,
'branch_id' => $room->branch_id,
'metric' => 'participant_minutes',
'quantity' => $minutes,
'metadata' => ['session_uuid' => $session->uuid, 'room_uuid' => $room->uuid],
'recorded_at' => now(),
]);
$this->maybeDebit($organization, $minutes, 'participant_minutes', $session->uuid);
}
public function recordRecordingStorage(Organization $organization, int $bytes, string $recordingUuid): void
{
UsageRecord::create([
'owner_ref' => $organization->owner_ref,
'organization_id' => $organization->id,
'metric' => 'recording_storage_bytes',
'quantity' => $bytes,
'metadata' => ['recording_uuid' => $recordingUuid],
'recorded_at' => now(),
]);
$this->billing->chargeStreaming($session);
}
public function recordAiMinutes(Organization $organization, int $minutes, string $sessionUuid): void
@@ -56,8 +27,6 @@ class UsageMeteringService
'metadata' => ['session_uuid' => $sessionUuid],
'recorded_at' => now(),
]);
$this->maybeDebit($organization, $minutes, 'ai_minutes', $sessionUuid);
}
public function usageSummary(Organization $organization, ?int $branchId = null, int $days = 30): array
@@ -70,50 +39,34 @@ class UsageMeteringService
$query->where('branch_id', $branchId);
}
return $query
$raw = $query
->selectRaw('metric, SUM(quantity) as total')
->groupBy('metric')
->pluck('total', 'metric')
->all();
$summary = [];
foreach ($raw as $metric => $total) {
if ($metric === 'streaming_hours') {
$summary['streaming_hours'] = round(((int) $total) / 100, 2);
} else {
$summary[$metric] = (int) $total;
}
}
return $summary;
}
protected function participantMinutes(Session $session): int
public function estimatedCostGhs(array $summary): array
{
if (! $session->started_at) {
return 0;
}
$streamingHours = (float) ($summary['streaming_hours'] ?? 0);
$recordingBytes = (int) ($summary['recording_storage_bytes'] ?? 0);
$fileBytes = (int) ($summary['file_storage_bytes'] ?? 0);
$ended = $session->ended_at ?? now();
$durationMinutes = max(1, (int) ceil($session->started_at->diffInSeconds($ended) / 60));
$participants = max(1, $session->participants()->count());
return $durationMinutes * $participants;
}
protected function maybeDebit(Organization $organization, int $quantity, string $metric, string $reference): void
{
$rates = config('meet.billing.rates', []);
$rateMinor = (int) ($rates[$metric] ?? 0);
if ($rateMinor <= 0) {
return;
}
$amountMinor = $quantity * $rateMinor;
try {
$this->billing->debit(
$organization->owner_ref,
$amountMinor,
'meet_'.$metric,
'meet-usage-'.$reference,
'Ladill Meet '.$metric,
);
} catch (\Throwable $e) {
Log::warning('Meet usage billing debit failed', [
'organization' => $organization->id,
'metric' => $metric,
'error' => $e->getMessage(),
]);
}
return [
'streaming' => round($streamingHours * $this->billing->pricePerHourGhs(), 2),
'recording_storage' => round($this->billing->storageGb($recordingBytes) * $this->billing->pricePerGbMonthGhs(), 2),
'file_storage' => round($this->billing->storageGb($fileBytes) * $this->billing->pricePerGbMonthGhs(), 2),
];
}
}