Transfer: two-step add-funds modal on insufficient balance
Deploy Ladill Transfer / deploy (push) Successful in 48s
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:
co-authored by
Claude Opus 4.8
parent
c9994129dc
commit
a0a706093b
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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');
|
||||
|
||||
Reference in New Issue
Block a user