Differentiate Billing from Wallet with storage usage breakdown.
Deploy Ladill Transfer / deploy (push) Successful in 27s
Deploy Ladill Transfer / deploy (push) Successful in 27s
Billing lists per-transfer storage costs and monthly estimates; Wallet stays focused on balance and top-up. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Qr;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\QrSetting;
|
use App\Models\QrSetting;
|
||||||
|
use App\Models\Transfer;
|
||||||
use App\Services\Billing\BillingClient;
|
use App\Services\Billing\BillingClient;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -49,10 +50,30 @@ class AccountController extends Controller
|
|||||||
$user = ladill_account();
|
$user = ladill_account();
|
||||||
[$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id);
|
[$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id);
|
||||||
|
|
||||||
|
$activeTransfers = Transfer::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('status', Transfer::STATUS_ACTIVE)
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||||
|
})
|
||||||
|
->orderByDesc('storage_bytes')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$storageBytes = (int) $activeTransfers->sum('storage_bytes');
|
||||||
|
$storageGb = round($storageBytes / (1024 * 1024 * 1024), 2);
|
||||||
|
$pricePerGb = (float) config('transfer.price_per_gb_month', 1.0);
|
||||||
|
$monthlyTotalGhs = round($activeTransfers->sum(fn (Transfer $transfer) => $transfer->monthlyCostGhs()), 2);
|
||||||
|
|
||||||
return view('qr.account.billing', [
|
return view('qr.account.billing', [
|
||||||
'balanceMinor' => $balanceMinor,
|
'balanceMinor' => $balanceMinor,
|
||||||
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
|
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
|
||||||
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
|
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
|
||||||
|
'activeTransfers' => $activeTransfers,
|
||||||
|
'storageBytes' => $storageBytes,
|
||||||
|
'storageGb' => $storageGb,
|
||||||
|
'pricePerGb' => $pricePerGb,
|
||||||
|
'monthlyTotalGhs' => $monthlyTotalGhs,
|
||||||
|
'walletUrl' => route('account.wallet'),
|
||||||
'topupUrl' => $this->topupUrl(),
|
'topupUrl' => $this->topupUrl(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,8 @@ class AfiaService
|
|||||||
- After creating a transfer: users get a share link and downloadable QR code; recipients scan ladill.com/q/{code} or open the link.
|
- After creating a transfer: users get a share link and downloadable QR code; recipients scan ladill.com/q/{code} or open the link.
|
||||||
- Files: browse all stored files across transfers with sizes and download counts.
|
- Files: browse all stored files across transfers with sizes and download counts.
|
||||||
- Analytics: download trends, top transfers, recent download activity.
|
- Analytics: download trends, top transfers, recent download activity.
|
||||||
- Wallet & Billing: prepaid storage is billed from the Ladill wallet (sidebar → Wallet). Top up at account.ladill.com/wallet.
|
- Wallet: Ladill wallet balance and top-up (sidebar → Wallet). Top up at account.ladill.com/wallet.
|
||||||
|
- Billing: storage usage, per-transfer monthly costs, and spend history (sidebar → Billing).
|
||||||
- Team: invite teammates to manage transfers together (sidebar → Team).
|
- Team: invite teammates to manage transfers together (sidebar → Team).
|
||||||
- Settings: notification preferences for transfer activity.
|
- Settings: notification preferences for transfer activity.
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,83 @@
|
|||||||
<x-user-layout>
|
<x-user-layout>
|
||||||
<x-slot name="title">Billing</x-slot>
|
<x-slot name="title">Billing</x-slot>
|
||||||
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
|
@php
|
||||||
|
$fmt = fn ($m) => 'GHS '.number_format($m / 100, 2);
|
||||||
|
$fmtBytes = function (int $bytes) {
|
||||||
|
if ($bytes >= 1073741824) {
|
||||||
|
return number_format($bytes / 1073741824, 2).' GB';
|
||||||
|
}
|
||||||
|
if ($bytes >= 1048576) {
|
||||||
|
return number_format($bytes / 1048576, 1).' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
return number_format($bytes / 1024, 0).' KB';
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
<div class="mx-auto max-w-3xl">
|
<div class="mx-auto max-w-3xl">
|
||||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Billing</h1>
|
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Billing</h1>
|
||||||
<p class="mt-0.5 text-sm text-slate-500">File storage is billed from your Ladill wallet (GHS {{ number_format(config('transfer.price_per_gb_month', 1), 2) }} per GB per month of retention).</p>
|
<p class="mt-0.5 text-sm text-slate-500">Storage is metered at GHS {{ number_format($pricePerGb, 2) }} per GB per month of retention and debited from your <a href="{{ $walletUrl }}" class="font-medium text-indigo-600 hover:text-indigo-800">Ladill wallet</a>.</p>
|
||||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
|
|
||||||
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Current balance</p>
|
<div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<p class="mt-1 text-3xl font-semibold text-slate-900">{{ $fmt($balanceMinor) }}</p>
|
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
<a href="{{ $topupUrl }}" class="mt-4 inline-flex rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Top up wallet</a>
|
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Storage used</p>
|
||||||
|
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmtBytes($storageBytes) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Monthly estimate</p>
|
||||||
|
<p class="mt-1.5 text-2xl font-semibold text-slate-900">GHS {{ number_format($monthlyTotalGhs, 2) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Spent on Transfer</p>
|
||||||
|
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($spentMinor) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5">
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Refunded / credited</p>
|
||||||
|
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($creditedMinor) }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
|
||||||
|
<h2 class="text-sm font-semibold text-slate-900">Active transfer storage</h2>
|
||||||
|
<span class="text-xs text-slate-400">GHS {{ number_format($pricePerGb, 2) }}/GB/mo</span>
|
||||||
|
</div>
|
||||||
|
@if($activeTransfers->isEmpty())
|
||||||
|
<p class="px-5 py-8 text-center text-sm text-slate-400">No active transfers with stored files yet.</p>
|
||||||
|
@else
|
||||||
|
<ul class="divide-y divide-slate-50">
|
||||||
|
@foreach($activeTransfers as $transfer)
|
||||||
|
<li class="flex items-center justify-between gap-4 px-5 py-3.5">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<a href="{{ route('transfer.transfers.show', $transfer) }}" class="truncate text-sm font-medium text-slate-900 hover:text-indigo-700">{{ $transfer->title }}</a>
|
||||||
|
<p class="text-xs text-slate-400">
|
||||||
|
{{ $fmtBytes($transfer->storage_bytes) }}
|
||||||
|
@if($transfer->expires_at)
|
||||||
|
· expires {{ $transfer->expires_at->format('M j, Y') }}
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 text-sm font-medium text-slate-700">GHS {{ number_format($transfer->monthlyCostGhs(), 2) }}<span class="text-xs text-slate-400">/mo</span></span>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
<div class="flex items-center justify-between border-t border-slate-100 px-5 py-3">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-slate-400">Monthly total</span>
|
||||||
|
<span class="text-sm font-semibold text-slate-900">GHS {{ number_format($monthlyTotalGhs, 2) }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-indigo-100 bg-indigo-50/60 px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wide text-indigo-600">Wallet balance</p>
|
||||||
|
<p class="mt-0.5 text-lg font-semibold text-slate-900">{{ $fmt($balanceMinor) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href="{{ $walletUrl }}" class="inline-flex rounded-lg border border-indigo-200 bg-white px-4 py-2 text-sm font-semibold text-indigo-700 hover:bg-indigo-50">View wallet</a>
|
||||||
|
<a href="{{ $topupUrl }}" class="inline-flex rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Top up</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-4 text-xs text-slate-400">Retention is prepaid from your wallet. Keep it funded so transfers stay available through their expiry date.</p>
|
||||||
</div>
|
</div>
|
||||||
</x-user-layout>
|
</x-user-layout>
|
||||||
|
|||||||
@@ -19,5 +19,9 @@
|
|||||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($creditedMinor) }}</p>
|
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($creditedMinor) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-4 text-xs text-slate-400">
|
||||||
|
For storage usage and monthly estimates, see
|
||||||
|
<a href="{{ route('account.billing') }}" class="font-medium text-indigo-600 hover:text-indigo-800">Billing</a>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</x-user-layout>
|
</x-user-layout>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Transfer;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class BillingPageTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function user(): User
|
||||||
|
{
|
||||||
|
return User::create(['public_id' => (string) Str::uuid(), 'name' => 'A', 'email' => 'a+'.uniqid().'@x.com']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fakeBillingApi(string $publicId): void
|
||||||
|
{
|
||||||
|
$base = rtrim((string) config('billing.api_url'), '/');
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
$base.'/balance*' => Http::response(['balance_minor' => 50000]),
|
||||||
|
$base.'/service-ledger*' => Http::response([
|
||||||
|
'spent_minor' => 1200,
|
||||||
|
'credited_minor' => 0,
|
||||||
|
'net_minor' => -1200,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_billing_page_shows_storage_breakdown(): void
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
$this->fakeBillingApi($user->public_id);
|
||||||
|
|
||||||
|
Transfer::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'title' => 'Client deliverables',
|
||||||
|
'retention_days' => 30,
|
||||||
|
'expires_at' => now()->addDays(30),
|
||||||
|
'status' => Transfer::STATUS_ACTIVE,
|
||||||
|
'storage_bytes' => 2 * 1024 * 1024 * 1024,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('account.billing'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Active transfer storage')
|
||||||
|
->assertSee('Client deliverables')
|
||||||
|
->assertSee('Monthly estimate')
|
||||||
|
->assertDontSee('Add funds');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_wallet_page_focuses_on_balance(): void
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
$this->fakeBillingApi($user->public_id);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('account.wallet'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Add funds')
|
||||||
|
->assertDontSee('Active transfer storage');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,5 +10,11 @@ abstract class TestCase extends BaseTestCase
|
|||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
$this->withoutVite();
|
$this->withoutVite();
|
||||||
|
|
||||||
|
foreach (['storage/framework/views', 'storage/framework/cache/data'] as $dir) {
|
||||||
|
if (! is_dir($path = base_path($dir))) {
|
||||||
|
mkdir($path, 0755, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user