diff --git a/app/Http/Controllers/Transfer/TransferController.php b/app/Http/Controllers/Transfer/TransferController.php index 1c98199..2d13a19 100644 --- a/app/Http/Controllers/Transfer/TransferController.php +++ b/app/Http/Controllers/Transfer/TransferController.php @@ -4,10 +4,12 @@ namespace App\Http\Controllers\Transfer; use App\Http\Controllers\Controller; use App\Models\Transfer; +use App\Services\Billing\BillingClient; use App\Services\Qr\QrImageGeneratorService; use App\Services\Qr\QrPdfExporter; use App\Services\Transfer\TransferService; use App\Services\Upload\ChunkedUploadService; +use App\Support\TransferWalletErrors; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -59,12 +61,21 @@ class TransferController extends Controller return view('transfer.transfers.index', compact('transfers', 'search', 'status', 'sort')); } - public function create(): View + public function create(BillingClient $billing): View { + $account = ladill_account(); + $balanceCedis = 0.0; + try { + $balanceCedis = $billing->balanceMinor((string) $account->public_id) / 100; + } catch (\Throwable) { + // Balance is informational on this page; ignore lookup failures. + } + return view('transfer.transfers.create', [ 'maxFiles' => (int) config('transfer.max_files_per_transfer', 20), 'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30), 'gracePeriodDays' => (int) config('transfer.grace_period_days', 15), + 'balanceCedis' => $balanceCedis, ]); } @@ -108,7 +119,12 @@ class TransferController extends Controller try { $transfer = $this->transfers->create($account, $data, $files); } catch (RuntimeException $e) { - return back()->withInput()->with('error', $e->getMessage()); + $redirect = back()->withInput()->with('error', $e->getMessage()); + if (TransferWalletErrors::isInsufficientBalance($e)) { + $redirect->with('open_topup_modal', 'transfer'); + } + + return $redirect; } finally { foreach ($uploadIds as $uploadId) { $this->chunkedUploads->cleanup($uploadId); diff --git a/app/Http/Controllers/WalletTopupController.php b/app/Http/Controllers/WalletTopupController.php new file mode 100644 index 0000000..e8e07e6 --- /dev/null +++ b/app/Http/Controllers/WalletTopupController.php @@ -0,0 +1,50 @@ +validate([ + 'amount' => ['required', 'numeric', 'min:5', 'max:5000'], + 'return_url' => ['nullable', 'url', 'max:2048'], + ]); + + $account = ladill_account(); + if ($account === null) { + return $request->expectsJson() + ? response()->json(['error' => 'Unauthenticated.'], 401) + : redirect()->route('sso.connect'); + } + + $returnUrl = $validated['return_url'] ?? (string) (url()->previous() ?: route('transfer.transfers.create')); + + try { + $checkoutUrl = $billing->topup((string) $account->public_id, (float) $validated['amount'], $returnUrl); + } catch (\Throwable) { + $checkoutUrl = ''; + } + + if ($checkoutUrl === '') { + return $request->expectsJson() + ? response()->json(['error' => 'Unable to start payment. Please try again.'], 422) + : back()->with('error', 'Unable to start payment. Please try again.'); + } + + return $request->expectsJson() + ? response()->json(['checkout_url' => $checkoutUrl]) + : redirect()->away($checkoutUrl); + } +} diff --git a/app/Services/Billing/BillingClient.php b/app/Services/Billing/BillingClient.php index 4c7caef..546fc33 100644 --- a/app/Services/Billing/BillingClient.php +++ b/app/Services/Billing/BillingClient.php @@ -35,6 +35,22 @@ class BillingClient return (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0); } + /** + * Start a Paystack checkout to top up the central wallet; returns the + * checkout URL. Backs the in-app two-step "Add funds" modal. + */ + public function topup(string $publicId, float $amount, string $returnUrl): string + { + $res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/topup', [ + 'user' => $publicId, + 'amount' => $amount, + 'return_url' => $returnUrl, + ]); + $res->throw(); + + return (string) ($res->json()['checkout_url'] ?? ''); + } + public function canAfford(string $publicId, int $amountMinor): bool { return (bool) ($this->get('/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor])['affordable'] ?? false); diff --git a/resources/views/components/user/service-topup-modal.blade.php b/resources/views/components/user/service-topup-modal.blade.php index f9a5675..bd4793a 100644 --- a/resources/views/components/user/service-topup-modal.blade.php +++ b/resources/views/components/user/service-topup-modal.blade.php @@ -1,84 +1,83 @@ @props([ 'id', - 'title' => 'Add credits', - 'description' => '', + 'title' => 'Add funds', 'topupAction', 'minTopup' => 5, 'suggestedAmount' => 10, - 'ladillWalletBalance' => 0, + 'pricePerAction' => null, + 'actionNoun' => 'action', 'serviceBalance' => null, - 'serviceBalanceLabel' => 'Current balance', + 'serviceBalanceLabel' => 'Wallet balance', + 'description' => '', 'openOnLoad' => false, 'returnUrl' => null, ]) -@php - // Single-wallet (siloing step 2): there is one Ladill wallet, so "pay from - // wallet into service credits" is a meaningless round-trip — top up the one - // wallet directly (Paystack). Transfer option removed. - $canPayFromWallet = false; -@endphp - -
+
+ + {{-- Header with step indicator --}}
-

{{ $title }}

- @if($description) -

{{ $description }}

- @endif +
+ + + +
+

How billing works

-
+

+ Ladill is pay-as-you-go. One wallet funds + every Ladill app, and you're only charged when you perform an action. +

- event.preventDefault(); - this.loading = true; - this.errorMsg = ''; + @if($description) +
{{ $description }}
+ @endif - try { - const res = await fetch(event.target.action, { - method: 'POST', - headers: { 'Accept': 'application/json' }, - body: new FormData(event.target), - }); - const data = await res.json(); - if (!res.ok || data.error || !data.checkout_url) { - this.errorMsg = data.error || 'Unable to start payment. Please try again.'; - this.loading = false; - return; - } +
+ @if($pricePerAction !== null) +
+ Each {{ $actionNoun }} costs + GHS {{ number_format((float) $pricePerAction, 4) }} +
+ @endif + @if($serviceBalance !== null) +
+ {{ $serviceBalanceLabel }} + GHS {{ number_format((float) $serviceBalance, 2) }} +
+ @endif +
- this.checkoutUrl = data.checkout_url; - this.showSheet = true; - this.loading = false; - } catch (error) { - this.errorMsg = 'Network error. Please try again.'; - this.loading = false; - } - }, - }" - @submit="submitTopup($event)"> +

+ Top up any amount — unused funds stay in your wallet for next time. +

+ +
+ + +
+
+ + {{-- STEP 2 — amount + payment (plain POST -> Paystack checkout) --}} + @csrf @if($returnUrl) @endif - @if($serviceBalance !== null) -
-

GHS {{ number_format((float) $serviceBalance, 2) }}

-

{{ $serviceBalanceLabel }}

-
- @endif - -
-
Minimum GHS {{ number_format($minTopup, 2) }}

- + - @if($canPayFromWallet) -
-

Payment method

-
- - -
-
- @else -

Pay with Paystack.

- @endif +

Pay securely with Paystack.

- - +
- - @include('partials.paystack-sheet')
diff --git a/resources/views/transfer/transfers/create.blade.php b/resources/views/transfer/transfers/create.blade.php index 49cadb8..f50fd1e 100644 --- a/resources/views/transfer/transfers/create.blade.php +++ b/resources/views/transfer/transfers/create.blade.php @@ -106,7 +106,13 @@
-

Monthly storage billing

+
+

Monthly storage billing

+ +

GHS {{ number_format($pricePerGb, 2) }} per GB per month, debited from your wallet. Files stay available while payments succeed. If a monthly renewal fails, files are kept for {{ $gracePeriodDays }} days before deletion.

@@ -126,5 +132,17 @@ + + {{-- Two-step add-funds modal (opens on insufficient balance instead of a wallet redirect) --}} + diff --git a/routes/web.php b/routes/web.php index eada163..cb4175d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Auth\SsoLoginController; use App\Http\Controllers\WalletBalanceController; +use App\Http\Controllers\WalletTopupController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\Public\QrScanController; use App\Http\Controllers\Public\TransferPublicController; @@ -38,6 +39,7 @@ Route::get('/q/{shortCode}/transfer/files/{file}', [TransferPublicController::cl Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/wallet/balance', [WalletBalanceController::class, 'balance'])->name('wallet.balance'); + Route::post('/wallet/topup', [WalletTopupController::class, 'store'])->name('user.wallet.topup'); Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread'); Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read');