Transfer: two-step add-funds modal on insufficient balance
Deploy Ladill Transfer / deploy (push) Successful in 48s

Creating a transfer that the wallet can't cover used to flash a bare error.
Detect the insufficient-balance case and open an in-app two-step modal (billing
explainer -> amount -> Paystack) on the create page instead, with an 'Add funds'
button to top up on demand.

- BillingClient::topup -> POST /api/billing/topup (Transfer already holds its key)
- WalletTopupController + route POST /wallet/topup (user.wallet.topup)
- store() flashes open_topup_modal on TransferWalletErrors::isInsufficientBalance
- service-topup-modal made two-step (uses <x-modal>, plain POST -> checkout)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
isaacclad
2026-06-26 07:15:38 +00:00
co-authored by Claude Opus 4.8
parent c9994129dc
commit a0a706093b
6 changed files with 168 additions and 87 deletions
@@ -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);
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers;
use App\Services\Billing\BillingClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
/**
* In-app wallet top-up. Posts to the central billing API to start a Paystack
* checkout against the one Ladill wallet, then hands the user to checkout. Backs
* the two-step "Add funds" modal so users never leave for the wallet page when a
* transfer can't be afforded.
*/
class WalletTopupController extends Controller
{
public function store(Request $request, BillingClient $billing): JsonResponse|RedirectResponse
{
$validated = $request->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);
}
}
+16
View File
@@ -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);
@@ -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
<x-modal :name="$id" :show="$openOnLoad" maxWidth="md">
<div class="flex min-h-full flex-col sm:min-h-0">
<div x-data="{ step: 1 }"
x-on:open-modal.window="String($event.detail) === @js($id) ? step = 1 : null"
class="flex min-h-full flex-col sm:min-h-0">
{{-- Header with step indicator --}}
<div class="border-b border-slate-100 px-5 py-4 pr-12 sm:px-6 sm:pr-14">
<h2 class="text-lg font-semibold text-slate-900">{{ $title }}</h2>
<div class="flex items-center gap-1.5">
<span class="h-1.5 w-6 rounded-full transition-colors" :class="step >= 1 ? 'bg-indigo-600' : 'bg-slate-200'"></span>
<span class="h-1.5 w-6 rounded-full transition-colors" :class="step >= 2 ? 'bg-indigo-600' : 'bg-slate-200'"></span>
<span class="ml-2 text-xs font-medium text-slate-400" x-text="'Step ' + step + ' of 2'"></span>
</div>
<h2 class="mt-2 text-lg font-semibold text-slate-900"
x-text="step === 1 ? 'How billing works' : @js($title)">How billing works</h2>
</div>
{{-- STEP 1 billing explainer --}}
<div x-show="step === 1" class="flex flex-1 flex-col p-5 sm:p-6">
<p class="text-sm leading-6 text-slate-600">
Ladill is <span class="font-medium text-slate-800">pay-as-you-go</span>. One wallet funds
every Ladill app, and you're only charged when you perform an action.
</p>
@if($description)
<p class="mt-1 text-sm text-slate-500">{{ $description }}</p>
<div class="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">{{ $description }}</div>
@endif
<div class="mt-4 space-y-2.5 rounded-xl border border-slate-200 bg-slate-50 p-4">
@if($pricePerAction !== null)
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">Each {{ $actionNoun }} costs</span>
<span class="font-semibold text-slate-900">GHS {{ number_format((float) $pricePerAction, 4) }}</span>
</div>
@endif
@if($serviceBalance !== null)
<div class="flex items-center justify-between text-sm">
<span class="text-slate-500">{{ $serviceBalanceLabel }}</span>
<span class="font-semibold text-slate-900">GHS {{ number_format((float) $serviceBalance, 2) }}</span>
</div>
@endif
</div>
<form action="{{ $topupAction }}" method="POST" class="flex flex-1 flex-col p-5 sm:p-6"
x-data="{
method: 'paystack',
showSheet: false,
checkoutUrl: '',
loading: false,
errorMsg: '',
async submitTopup(event) {
if (this.method !== 'paystack' || window.innerWidth >= 768) return;
<p class="mt-4 text-xs leading-5 text-slate-500">
Top up any amount unused funds stay in your wallet for next time.
</p>
event.preventDefault();
this.loading = true;
this.errorMsg = '';
<div class="mt-6 flex flex-col gap-2 sm:mt-auto sm:flex-row sm:justify-end">
<button type="button" @click="$dispatch('close-modal', '{{ $id }}')"
data-close-modal="{{ $id }}"
class="hidden rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 sm:inline-flex">
Not now
</button>
<button type="button" @click="step = 2" class="btn-primary w-full sm:w-auto">
Add funds
</button>
</div>
</div>
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;
}
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)">
{{-- STEP 2 amount + payment (plain POST -> Paystack checkout) --}}
<form x-show="step === 2" x-cloak action="{{ $topupAction }}" method="POST" class="flex flex-1 flex-col p-5 sm:p-6">
@csrf
@if($returnUrl)
<input type="hidden" name="return_url" value="{{ $returnUrl }}">
@endif
@if($serviceBalance !== null)
<div class="mb-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-center">
<p class="text-xl font-bold text-slate-900">GHS {{ number_format((float) $serviceBalance, 2) }}</p>
<p class="text-xs text-slate-500">{{ $serviceBalanceLabel }}</p>
</div>
@endif
<div x-show="errorMsg" x-cloak class="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700" x-text="errorMsg"></div>
<div>
<label class="text-sm font-medium text-slate-700">Amount (GHS)</label>
<input type="number" name="amount" min="{{ $minTopup }}" max="5000" step="0.01"
@@ -88,37 +87,17 @@
<p class="mt-1.5 text-xs text-slate-500">Minimum GHS {{ number_format($minTopup, 2) }}</p>
</div>
<input type="hidden" name="payment_method" value="paystack" @if($canPayFromWallet) x-model="method" @endif>
<input type="hidden" name="payment_method" value="paystack">
@if($canPayFromWallet)
<div class="mt-4">
<p class="mb-2 text-xs font-medium text-slate-600">Payment method</p>
<div class="flex rounded-xl border border-slate-200 bg-white p-0.5 text-sm">
<button type="button" @click="method = 'paystack'"
:class="method === 'paystack' ? 'bg-indigo-600 text-white' : 'text-slate-600'"
class="flex-1 rounded-lg py-2 font-medium transition">Paystack</button>
<button type="button" @click="method = 'wallet'"
:class="method === 'wallet' ? 'bg-indigo-600 text-white' : 'text-slate-600'"
class="flex-1 rounded-lg py-2 font-medium transition">Wallet (GHS {{ number_format($ladillWalletBalance, 2) }})</button>
</div>
</div>
@else
<p class="mt-4 text-xs text-slate-500">Pay with Paystack.</p>
@endif
<p class="mt-4 text-xs text-slate-500">Pay securely with Paystack.</p>
<div class="mt-6 flex flex-col gap-2 sm:mt-auto sm:flex-row sm:justify-end">
<button type="button" @click="$dispatch('close-modal', '{{ $id }}')"
data-close-modal="{{ $id }}"
<button type="button" @click="step = 1"
class="hidden rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 sm:inline-flex">
Cancel
</button>
<button type="submit" :disabled="loading"
class="btn-primary w-full sm:w-auto">
<span x-text="loading ? 'Processing…' : 'Add credits'">Add credits</span>
Back
</button>
<button type="submit" class="btn-primary w-full sm:w-auto">Continue to payment</button>
</div>
@include('partials.paystack-sheet')
</form>
</div>
</x-modal>
@@ -106,7 +106,13 @@
</div>
<div class="rounded-xl border border-indigo-100 bg-indigo-50/60 px-4 py-3 text-sm text-slate-600">
<div class="flex items-start justify-between gap-3">
<p class="font-medium text-slate-900">Monthly storage billing</p>
<button type="button" @click="$dispatch('open-modal', 'transfer')"
class="shrink-0 text-xs font-semibold text-indigo-600 hover:underline">
Wallet: GHS {{ number_format($balanceCedis, 2) }} · Add funds
</button>
</div>
<p class="mt-1">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.</p>
</div>
@@ -126,5 +132,17 @@
</button>
</div>
</form>
{{-- Two-step add-funds modal (opens on insufficient balance instead of a wallet redirect) --}}
<x-user.service-topup-modal
id="transfer"
title="Add funds"
:topup-action="route('user.wallet.topup')"
action-noun="transfer"
:service-balance="(float) $balanceCedis"
service-balance-label="Wallet balance"
description="Ladill Transfer bills monthly for storage — GHS {{ number_format($pricePerGb, 2) }} per GB per month, from your one Ladill wallet. Top up to create or keep your transfers."
:return-url="route('transfer.transfers.create')"
:open-on-load="session('open_topup_modal') === 'transfer'" />
</div>
</x-user-layout>
+2
View File
@@ -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');