diff --git a/app/Http/Controllers/WalletTopupController.php b/app/Http/Controllers/WalletTopupController.php new file mode 100644 index 0000000..d0fe811 --- /dev/null +++ b/app/Http/Controllers/WalletTopupController.php @@ -0,0 +1,51 @@ +validate([ + 'amount' => ['required', 'numeric', 'min:5', 'max:5000'], + 'return_url' => ['nullable', 'url', 'max:2048'], + ]); + + $returnUrl = $validated['return_url'] ?? (string) (url()->previous() ?: url('/')); + + try { + $checkoutUrl = $identity->walletTopup( + (string) $request->user()->public_id, + (float) $validated['amount'], + $returnUrl, + ); + } catch (\Throwable) { + $checkoutUrl = ''; + } + + if ($checkoutUrl === '') { + if ($request->expectsJson()) { + return response()->json(['error' => 'Unable to start payment. Please try again.'], 422); + } + + return back()->with('error', 'Unable to start payment. Please try again.'); + } + + if ($request->expectsJson()) { + return response()->json(['checkout_url' => $checkoutUrl]); + } + + return redirect()->away($checkoutUrl); + } +} diff --git a/app/Services/Identity/IdentityClient.php b/app/Services/Identity/IdentityClient.php index 6033f57..9553b16 100644 --- a/app/Services/Identity/IdentityClient.php +++ b/app/Services/Identity/IdentityClient.php @@ -43,6 +43,24 @@ class IdentityClient return (array) $response->json('data', []); } + /** + * Start a central-wallet Paystack top-up for an account and return the + * Paystack checkout URL. Backs the in-app "Add funds" modal so users top up + * without leaving the app for the central wallet page. + */ + public function walletTopup(string $publicId, float $amount, string $returnUrl): string + { + $response = $this->request()->post($this->url('/identity/wallet-topup-account'), [ + 'user' => $publicId, + 'amount' => $amount, + 'return_url' => $returnUrl, + ]); + + $response->throw(); + + return (string) $response->json('data.checkout_url', ''); + } + private function request() { return Http::withToken((string) config('identity.api_key')) diff --git a/resources/views/components/user/service-topup-modal.blade.php b/resources/views/components/user/service-topup-modal.blade.php index f9a5675..98ce945 100644 --- a/resources/views/components/user/service-topup-modal.blade.php +++ b/resources/views/components/user/service-topup-modal.blade.php @@ -1,13 +1,15 @@ @props([ 'id', - 'title' => 'Add credits', + 'title' => 'Add funds', 'description' => '', 'topupAction', 'minTopup' => 5, 'suggestedAmount' => 10, 'ladillWalletBalance' => 0, 'serviceBalance' => null, - 'serviceBalanceLabel' => 'Current balance', + 'serviceBalanceLabel' => 'Wallet balance', + 'pricePerAction' => null, + 'actionNoun' => 'action', 'openOnLoad' => false, 'returnUrl' => null, ]) @@ -17,18 +19,68 @@ // wallet into service credits" is a meaningless round-trip — top up the one // wallet directly (Paystack). Transfer option removed. $canPayFromWallet = false; + + // Balance shown in the explainer — prefer the service balance, fall back to + // the central wallet figure. + $shownBalance = $serviceBalance !== null ? (float) $serviceBalance : (float) $ladillWalletBalance; @endphp -
+
+ + {{-- Header with step indicator --}}
-

{{ $title }}

- @if($description) -

{{ $description }}

- @endif +
+ + + +
+

How billing works

+

{{ $description }}

-
+

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

+ +
+ @if($pricePerAction !== null) +
+ Each {{ $actionNoun }} costs + GHS {{ number_format((float) $pricePerAction, 2) }} +
+ @endif +
+ {{ $serviceBalanceLabel }} + GHS {{ number_format($shownBalance, 2) }} +
+
+ +

+ Top up any amount — unused funds stay in your wallet for next time. You can withdraw + them whenever you like. +

+ +
+ + +
+
+ + {{-- STEP 2 — amount + payment --}} + @endif - @if($serviceBalance !== null) -
-

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

-

{{ $serviceBalanceLabel }}

-
- @endif -
@@ -88,33 +133,18 @@

Minimum GHS {{ number_format($minTopup, 2) }}

- + - @if($canPayFromWallet) -
-

Payment method

-
- - -
-
- @else -

Pay with Paystack.

- @endif +

Pay securely with Paystack.

-
diff --git a/resources/views/layouts/user.blade.php b/resources/views/layouts/user.blade.php index 6eb82bd..27bc584 100644 --- a/resources/views/layouts/user.blade.php +++ b/resources/views/layouts/user.blade.php @@ -207,19 +207,19 @@ document.addEventListener('alpine:init', () => { return this.needsTopupFor(this.requiresPayment); }, openTopup() { + // Prefer the in-app two-step add-funds modal; only fall back to the + // central wallet page when no modal is wired on this view. + if (this.modalId) { + window.dispatchEvent(new CustomEvent('open-modal', { + detail: this.modalId, + bubbles: true, + })); + return; + } + if (this.topupUrl) { window.location.href = this.topupUrl; - return; } - - if (! this.modalId) { - return; - } - - window.dispatchEvent(new CustomEvent('open-modal', { - detail: this.modalId, - bubbles: true, - })); }, goOrTopup(event, href = null, requiresPayment = null) { const paymentRequired = requiresPayment === null diff --git a/resources/views/qr-codes/create.blade.php b/resources/views/qr-codes/create.blade.php index 5147d30..2245ffd 100644 --- a/resources/views/qr-codes/create.blade.php +++ b/resources/views/qr-codes/create.blade.php @@ -15,6 +15,7 @@ balance: @js((float) $wallet->spendableBalance()), price: @js((float) $pricePerQr), topupUrl: @js($topupUrl), + topupModalId: 'qr', type: @js(old('type', $prefill['type'] ?? $defaultType ?? 'url')), })"> @@ -254,5 +255,17 @@
+ {{-- Two-step add-funds modal (insufficient balance opens this instead of redirecting to the wallet) --}} + + diff --git a/resources/views/qr-codes/index.blade.php b/resources/views/qr-codes/index.blade.php index d4fa0f6..b2b44a2 100644 --- a/resources/views/qr-codes/index.blade.php +++ b/resources/views/qr-codes/index.blade.php @@ -6,6 +6,7 @@ balance: @js((float) $wallet->spendableBalance()), price: @js((float) $pricePerQr), topupUrl: @js($topupUrl), + modalId: 'qr', href: @js(route('user.qr-codes.create')), })"> @foreach(['success', 'error'] as $flash) @@ -82,5 +83,17 @@ @endif + + {{-- Two-step add-funds modal (insufficient balance opens this instead of redirecting to the wallet) --}} + diff --git a/resources/views/qr-codes/partials/qr-customizer-script.blade.php b/resources/views/qr-codes/partials/qr-customizer-script.blade.php index 654989a..a911b2d 100644 --- a/resources/views/qr-codes/partials/qr-customizer-script.blade.php +++ b/resources/views/qr-codes/partials/qr-customizer-script.blade.php @@ -227,11 +227,8 @@ this.schedulePreview(); }, redirectTopup() { - if (this.topupUrl) { - window.location.href = this.topupUrl; - return true; - } - + // Prefer the in-app two-step add-funds modal; only fall back to the + // central wallet page when no modal is wired on this view. if (this.topupModalId) { window.dispatchEvent(new CustomEvent('open-modal', { detail: this.topupModalId, @@ -240,6 +237,11 @@ return true; } + if (this.topupUrl) { + window.location.href = this.topupUrl; + return true; + } + return false; }, checkBalance(event) { diff --git a/routes/web.php b/routes/web.php index 7da1c6f..403820e 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\Qr\AccountController; @@ -36,6 +37,7 @@ Route::get('/q/{shortCode}/app-icon', [QrScanController::class, 'appIcon'])->nam 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');