Bill Meet streaming hourly and storage per GB like Transfer.
Deploy Ladill Meet / deploy (push) Successful in 54s
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:
@@ -68,5 +68,9 @@ MICROSOFT_CALENDAR_CLIENT_SECRET=
|
||||
MICROSOFT_CALENDAR_REDIRECT_URI=https://meet.ladill.com/settings/calendar/callback
|
||||
MEET_FILES_DISK=local
|
||||
MEET_FILES_EXPIRY_DAYS=30
|
||||
MEET_PRICE_PER_HOUR=0.30
|
||||
MEET_PRICE_PER_GB_MONTH=0.30
|
||||
MEET_BILLING_PERIOD_DAYS=30
|
||||
MEET_GRACE_PERIOD_DAYS=15
|
||||
LADILL_DRIVE_API_URL=
|
||||
LADILL_DRIVE_API_KEY_MEET=
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Meet\MeetBillingService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ProcessMeetBillingCommand extends Command
|
||||
{
|
||||
protected $signature = 'meet:process-billing';
|
||||
|
||||
protected $description = 'Renew monthly Meet recording and file storage billing';
|
||||
|
||||
public function handle(MeetBillingService $billing): int
|
||||
{
|
||||
$result = $billing->processDueStorageRenewals();
|
||||
|
||||
$this->info(sprintf(
|
||||
'Meet storage billing: %d renewed, %d entered grace, %d deleted.',
|
||||
$result['renewed'],
|
||||
$result['graced'],
|
||||
$result['deleted'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,11 @@ class AdminController extends Controller
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
|
||||
$summary = $this->metering->usageSummary($organization, $branchId);
|
||||
$costs = $this->metering->estimatedCostGhs($summary);
|
||||
$quotas = $this->licenses->quotas($organization);
|
||||
$tier = $organization->license_tier ?? 'standard';
|
||||
$billing = app(\App\Services\Meet\MeetBillingService::class);
|
||||
|
||||
return view('meet.admin.usage', compact('organization', 'summary', 'quotas', 'tier'));
|
||||
return view('meet.admin.usage', compact('organization', 'summary', 'costs', 'quotas', 'tier', 'billing'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ class RecordingController extends Controller
|
||||
$this->authorizeAbility($request, 'meetings.view');
|
||||
$this->authorizeOwner($request, $recording);
|
||||
abort_unless($recording->isReady() && $recording->storage_path, 404);
|
||||
abort_unless(app(\App\Services\Meet\MeetBillingService::class)->storageIsAccessible($recording), 402, 'Recording storage billing is overdue. Top up your Ladill wallet to download this recording.');
|
||||
|
||||
return Storage::disk(config('meet.recordings.disk', 'local'))
|
||||
->download($recording->storage_path, $recording->session->room->title.'.mp4');
|
||||
|
||||
@@ -17,6 +17,7 @@ class Recording extends Model
|
||||
'uuid', 'owner_ref', 'session_id', 'status', 'layout', 'storage_path',
|
||||
'thumbnail_path', 'file_size', 'duration_seconds', 'started_by_ref',
|
||||
'started_at', 'ended_at', 'failure_reason', 'metadata',
|
||||
'storage_paid_until', 'storage_grace_ends_at', 'storage_last_billed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -24,6 +25,9 @@ class Recording extends Model
|
||||
return [
|
||||
'started_at' => 'datetime',
|
||||
'ended_at' => 'datetime',
|
||||
'storage_paid_until' => 'datetime',
|
||||
'storage_grace_ends_at' => 'datetime',
|
||||
'storage_last_billed_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -17,11 +17,17 @@ class SessionFile extends Model
|
||||
protected $fillable = [
|
||||
'uuid', 'owner_ref', 'room_id', 'session_id', 'uploaded_by_ref', 'uploaded_by_name',
|
||||
'original_name', 'storage_path', 'mime_type', 'file_size', 'drive_file_id', 'expires_at',
|
||||
'storage_paid_until', 'storage_grace_ends_at', 'storage_last_billed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['expires_at' => 'datetime'];
|
||||
return [
|
||||
'expires_at' => 'datetime',
|
||||
'storage_paid_until' => 'datetime',
|
||||
'storage_grace_ends_at' => 'datetime',
|
||||
'storage_last_billed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -171,10 +171,11 @@ return [
|
||||
],
|
||||
|
||||
'billing' => [
|
||||
'rates' => [
|
||||
'participant_minutes' => (int) env('MEET_RATE_PARTICIPANT_MINUTE_MINOR', 0),
|
||||
'ai_minutes' => (int) env('MEET_RATE_AI_MINUTE_MINOR', 0),
|
||||
],
|
||||
'price_per_hour_ghs' => (float) env('MEET_PRICE_PER_HOUR', 0.30),
|
||||
'price_per_gb_month_ghs' => (float) env('MEET_PRICE_PER_GB_MONTH', 0.30),
|
||||
'billing_period_days' => (int) env('MEET_BILLING_PERIOD_DAYS', 30),
|
||||
'grace_period_days' => (int) env('MEET_GRACE_PERIOD_DAYS', 15),
|
||||
'minimum_streaming_hours' => (int) env('MEET_MIN_STREAMING_HOURS', 1),
|
||||
],
|
||||
|
||||
'audit_actions' => [
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('meet_recordings', function (Blueprint $table) {
|
||||
$table->timestamp('storage_paid_until')->nullable()->after('file_size');
|
||||
$table->timestamp('storage_grace_ends_at')->nullable()->after('storage_paid_until');
|
||||
$table->timestamp('storage_last_billed_at')->nullable()->after('storage_grace_ends_at');
|
||||
});
|
||||
|
||||
Schema::table('meet_session_files', function (Blueprint $table) {
|
||||
$table->timestamp('storage_paid_until')->nullable()->after('file_size');
|
||||
$table->timestamp('storage_grace_ends_at')->nullable()->after('storage_paid_until');
|
||||
$table->timestamp('storage_last_billed_at')->nullable()->after('storage_grace_ends_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('meet_recordings', function (Blueprint $table) {
|
||||
$table->dropColumn(['storage_paid_until', 'storage_grace_ends_at', 'storage_last_billed_at']);
|
||||
});
|
||||
|
||||
Schema::table('meet_session_files', function (Blueprint $table) {
|
||||
$table->dropColumn(['storage_paid_until', 'storage_grace_ends_at', 'storage_last_billed_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,22 +1,55 @@
|
||||
<x-app-layout title="Usage & billing">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Usage & quotas</h1>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Usage & billing</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ $organization->name }} · {{ ucfirst($tier) }} plan</p>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<h2 class="font-medium text-slate-900">Pay-as-you-go rates</h2>
|
||||
<ul class="mt-3 space-y-2 text-sm text-slate-600">
|
||||
<li>Live meetings: <span class="font-medium text-slate-900">GHS {{ number_format($billing->pricePerHourGhs(), 2) }} / hour</span> (billed when a session ends)</li>
|
||||
<li>Recordings & shared files: <span class="font-medium text-slate-900">GHS {{ number_format($billing->pricePerGbMonthGhs(), 2) }} / GB / month</span> (same as Ladill Transfer)</li>
|
||||
</ul>
|
||||
<p class="mt-3 text-xs text-slate-500">Charges debit your Ladill wallet. Storage renews monthly; unpaid items enter a {{ $billing->gracePeriodDays() }}-day grace period before deletion.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
@foreach ($quotas as $metric => $limit)
|
||||
@php
|
||||
$rows = [
|
||||
'streaming_hours' => ['label' => 'Streaming hours', 'used' => number_format($summary['streaming_hours'] ?? 0, 1), 'cost' => $costs['streaming'] ?? 0],
|
||||
'recording_storage_bytes' => ['label' => 'Recording storage', 'used' => number_format(($summary['recording_storage_bytes'] ?? 0) / 1073741824, 2).' GB', 'cost' => $costs['recording_storage'] ?? 0],
|
||||
'file_storage_bytes' => ['label' => 'Shared file storage', 'used' => number_format(($summary['file_storage_bytes'] ?? 0) / 1073741824, 2).' GB', 'cost' => $costs['file_storage'] ?? 0],
|
||||
'ai_minutes' => ['label' => 'AI minutes', 'used' => number_format($summary['ai_minutes'] ?? 0), 'cost' => null],
|
||||
];
|
||||
@endphp
|
||||
|
||||
@foreach ($rows as $metric => $row)
|
||||
@php
|
||||
$used = (int) ($summary[$metric] ?? 0);
|
||||
$pct = $limit > 0 ? min(100, round(($used / $limit) * 100)) : 0;
|
||||
$limit = (int) ($quotas[$metric] ?? 0);
|
||||
$pct = ($metric === 'streaming_hours' && $limit > 0)
|
||||
? min(100, round((($summary['streaming_hours'] ?? 0) / $limit) * 100))
|
||||
: (($metric !== 'streaming_hours' && $limit > 0)
|
||||
? min(100, round(((int) ($summary[$metric] ?? 0) / $limit) * 100))
|
||||
: 0);
|
||||
@endphp
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-medium text-slate-900">{{ str_replace('_', ' ', ucfirst($metric)) }}</h2>
|
||||
<span class="text-sm text-slate-500">{{ number_format($used) }} / {{ number_format($limit) }}</span>
|
||||
</div>
|
||||
<div class="mt-3 h-2 overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-full rounded-full bg-indigo-500" style="width: {{ $pct }}%"></div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-medium text-slate-900">{{ $row['label'] }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Last 30 days: {{ $row['used'] }}</p>
|
||||
</div>
|
||||
@if ($row['cost'] !== null)
|
||||
<span class="text-sm font-medium text-slate-900">GHS {{ number_format($row['cost'], 2) }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@if ($limit > 0)
|
||||
<div class="mt-3 flex items-center justify-between text-xs text-slate-500">
|
||||
<span>Plan allowance</span>
|
||||
<span>{{ $metric === 'streaming_hours' ? number_format($limit) : number_format($limit / 1073741824, 0).' GB' }}</span>
|
||||
</div>
|
||||
<div class="mt-2 h-2 overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-full rounded-full bg-indigo-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command('meet:send-reminders')->everyFiveMinutes();
|
||||
Schedule::command('meet:process-billing')->daily()->withoutOverlapping();
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Room;
|
||||
use App\Models\Session;
|
||||
use App\Services\Meet\MeetBillingService;
|
||||
use App\Services\Meet\UsageMeteringService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MeetBillingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function fakeAffordableBilling(): void
|
||||
{
|
||||
$base = rtrim((string) config('billing.api_url'), '/');
|
||||
|
||||
Http::fake([
|
||||
$base.'/can-afford*' => Http::response(['affordable' => true]),
|
||||
$base.'/debit' => Http::response(['ok' => true]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_session_end_records_streaming_usage_and_debits_wallet(): void
|
||||
{
|
||||
$this->fakeAffordableBilling();
|
||||
|
||||
$organization = Organization::create([
|
||||
'owner_ref' => 'owner-001',
|
||||
'name' => 'Billing Org',
|
||||
'slug' => 'billing-org',
|
||||
'timezone' => 'UTC',
|
||||
]);
|
||||
|
||||
$room = Room::create([
|
||||
'owner_ref' => 'owner-001',
|
||||
'organization_id' => $organization->id,
|
||||
'host_user_ref' => 'owner-001',
|
||||
'title' => 'Billing Room',
|
||||
'type' => 'instant',
|
||||
'status' => 'live',
|
||||
'settings' => config('meet.default_settings'),
|
||||
]);
|
||||
|
||||
$session = Session::create([
|
||||
'owner_ref' => 'owner-001',
|
||||
'room_id' => $room->id,
|
||||
'media_room_name' => 'room-test',
|
||||
'status' => 'ended',
|
||||
'started_at' => now()->subHour(),
|
||||
'ended_at' => now(),
|
||||
]);
|
||||
|
||||
app(UsageMeteringService::class)->recordSessionUsage($session);
|
||||
|
||||
$this->assertDatabaseHas('meet_usage_records', [
|
||||
'organization_id' => $organization->id,
|
||||
'metric' => 'streaming_hours',
|
||||
]);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '/debit')
|
||||
&& ($request->data()['source'] ?? '') === 'meet_streaming');
|
||||
}
|
||||
|
||||
public function test_storage_renewal_enters_grace_when_wallet_is_empty(): void
|
||||
{
|
||||
$base = rtrim((string) config('billing.api_url'), '/');
|
||||
Http::fake([
|
||||
$base.'/can-afford*' => Http::response(['affordable' => false]),
|
||||
]);
|
||||
|
||||
$organization = Organization::create([
|
||||
'owner_ref' => 'owner-002',
|
||||
'name' => 'Grace Org',
|
||||
'slug' => 'grace-org',
|
||||
'timezone' => 'UTC',
|
||||
]);
|
||||
|
||||
$room = Room::create([
|
||||
'owner_ref' => 'owner-002',
|
||||
'organization_id' => $organization->id,
|
||||
'host_user_ref' => 'owner-002',
|
||||
'title' => 'Grace Room',
|
||||
'type' => 'instant',
|
||||
'status' => 'ended',
|
||||
'settings' => config('meet.default_settings'),
|
||||
]);
|
||||
|
||||
$session = Session::create([
|
||||
'owner_ref' => 'owner-002',
|
||||
'room_id' => $room->id,
|
||||
'media_room_name' => 'room-grace',
|
||||
'status' => 'ended',
|
||||
'started_at' => now()->subDay(),
|
||||
'ended_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$file = \App\Models\SessionFile::create([
|
||||
'owner_ref' => 'owner-002',
|
||||
'room_id' => $room->id,
|
||||
'session_id' => $session->id,
|
||||
'uploaded_by_ref' => 'owner-002',
|
||||
'uploaded_by_name' => 'Host',
|
||||
'original_name' => 'notes.pdf',
|
||||
'storage_path' => 'meet-files/test/notes.pdf',
|
||||
'file_size' => 1073741824,
|
||||
'storage_paid_until' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$result = app(MeetBillingService::class)->processDueStorageRenewals();
|
||||
|
||||
$file->refresh();
|
||||
$this->assertSame(1, $result['graced']);
|
||||
$this->assertNotNull($file->storage_grace_ends_at);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Models\Room;
|
||||
use App\Models\Session;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MeetWebTest extends TestCase
|
||||
@@ -45,6 +46,13 @@ class MeetWebTest extends TestCase
|
||||
'user_ref' => $this->user->public_id,
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$base = rtrim((string) config('billing.api_url'), '/');
|
||||
Http::fake([
|
||||
$base.'/can-afford*' => Http::response(['affordable' => true]),
|
||||
$base.'/debit' => Http::response(['ok' => true]),
|
||||
$base.'/balance*' => Http::response(['balance_minor' => 100000]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_health_endpoint(): void
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Session;
|
||||
use App\Services\Meet\MeetBillingService;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MeetBillingServiceTest extends TestCase
|
||||
{
|
||||
public function test_streaming_hours_are_ceiled_with_minimum_one_hour(): void
|
||||
{
|
||||
$billing = app(MeetBillingService::class);
|
||||
|
||||
$session = new Session([
|
||||
'started_at' => now()->subMinutes(10),
|
||||
'ended_at' => now(),
|
||||
]);
|
||||
|
||||
$this->assertSame(1.0, $billing->streamingHoursFor($session));
|
||||
}
|
||||
|
||||
public function test_streaming_amount_minor_matches_hourly_rate(): void
|
||||
{
|
||||
config(['meet.billing.price_per_hour_ghs' => 0.30]);
|
||||
|
||||
$billing = app(MeetBillingService::class);
|
||||
|
||||
$this->assertSame(30, $billing->amountMinorForStreamingHours(1));
|
||||
$this->assertSame(60, $billing->amountMinorForStreamingHours(2));
|
||||
}
|
||||
|
||||
public function test_storage_amount_minor_matches_gb_monthly_rate(): void
|
||||
{
|
||||
config(['meet.billing.price_per_gb_month_ghs' => 0.30]);
|
||||
|
||||
$billing = app(MeetBillingService::class);
|
||||
$oneGb = 1073741824;
|
||||
|
||||
$this->assertSame(30, $billing->amountMinorForStorageBytes($oneGb));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user