Collect MoMo MSISDN and open waiting page on Pay.
Deploy Ladill Mini / deploy (push) Successful in 42s
Deploy Ladill Mini / deploy (push) Successful in 42s
Payment QR was still Paystack-only: no customer_phone and mobile iframe sheet. Require MoMo number, pass it to Ladill Pay, and full-page redirect for mtn_momo waiting URLs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -27,6 +27,7 @@ class PaymentController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'amount' => 'required|numeric|min:0.01',
|
||||
'customer_phone' => 'required|string|max:32',
|
||||
]);
|
||||
|
||||
try {
|
||||
@@ -40,7 +41,10 @@ class PaymentController extends Controller
|
||||
}
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['checkout_url' => $result['checkout_url']]);
|
||||
return response()->json([
|
||||
'checkout_url' => $result['checkout_url'],
|
||||
'provider' => $result['provider'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->away($result['checkout_url']);
|
||||
|
||||
@@ -25,8 +25,8 @@ class MiniPaymentService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{amount: float} $data
|
||||
* @return array{payment: MiniPayment, checkout_url: string}
|
||||
* @param array{amount: float, customer_phone?: string} $data
|
||||
* @return array{payment: MiniPayment, checkout_url: string, provider: string}
|
||||
*/
|
||||
public function initiate(QrCode $qrCode, array $data): array
|
||||
{
|
||||
@@ -39,6 +39,11 @@ class MiniPaymentService
|
||||
throw new RuntimeException('Enter an amount greater than zero.');
|
||||
}
|
||||
|
||||
$customerPhone = trim((string) ($data['customer_phone'] ?? ''));
|
||||
if ($customerPhone === '') {
|
||||
throw new RuntimeException('Enter your MoMo number to pay.');
|
||||
}
|
||||
|
||||
$qrCode->loadMissing('user');
|
||||
$amountMinor = (int) round($amountGhs * 100);
|
||||
$reference = 'MINP-'.strtoupper(Str::random(16));
|
||||
@@ -52,7 +57,7 @@ class MiniPaymentService
|
||||
'currency' => $qrCode->content()['currency'] ?? 'GHS',
|
||||
'payer_name' => null,
|
||||
'payer_email' => null,
|
||||
'payer_phone' => null,
|
||||
'payer_phone' => $customerPhone,
|
||||
'payer_note' => null,
|
||||
'status' => MiniPayment::STATUS_PENDING,
|
||||
'payment_reference' => null,
|
||||
@@ -64,6 +69,7 @@ class MiniPaymentService
|
||||
'source_service' => 'mini',
|
||||
'source_ref' => (string) $qrCode->id,
|
||||
'callback_url' => LadillLink::path($qrCode->short_code, 'pay/callback'),
|
||||
'customer_phone' => $customerPhone,
|
||||
'line_items' => [
|
||||
[
|
||||
'name' => $businessName,
|
||||
@@ -93,6 +99,7 @@ class MiniPaymentService
|
||||
return [
|
||||
'payment' => $payment->fresh(),
|
||||
'checkout_url' => $checkoutUrl,
|
||||
'provider' => (string) ($payOrder['provider'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Services\Pay;
|
||||
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Client for the platform Ladill Pay HTTP API — checkout and settlement for
|
||||
* merchant↔buyer money. Paystack is the processor today; settlement lands in
|
||||
* the one UserWallet via Platform Billing.
|
||||
* merchant↔buyer money. Provider is configured on the platform
|
||||
* (PAY_DEFAULT_PROVIDER); settlement lands via wallet or direct MoMo.
|
||||
*/
|
||||
class PayClient
|
||||
{
|
||||
@@ -25,9 +27,8 @@ class PayClient
|
||||
public function createCheckout(array $payload): array
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/checkouts', $payload);
|
||||
$res->throw();
|
||||
|
||||
return (array) $res->json();
|
||||
return $this->jsonOrFail($res, 'Could not start checkout. Please try again.');
|
||||
}
|
||||
|
||||
public function verify(string $reference): array
|
||||
@@ -35,16 +36,36 @@ class PayClient
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/checkouts/verify', [
|
||||
'reference' => $reference,
|
||||
]);
|
||||
$res->throw();
|
||||
|
||||
return (array) $res->json();
|
||||
return $this->jsonOrFail($res, 'Could not verify payment. Please try again.');
|
||||
}
|
||||
|
||||
public function show(string $reference): array
|
||||
{
|
||||
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->get($this->base().'/orders/'.$reference);
|
||||
$res->throw();
|
||||
|
||||
return $this->jsonOrFail($res, 'Could not load payment order.');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function jsonOrFail(Response $res, string $fallback): array
|
||||
{
|
||||
if ($res->failed()) {
|
||||
throw new RuntimeException($this->errorMessage($res, $fallback));
|
||||
}
|
||||
|
||||
return (array) $res->json();
|
||||
}
|
||||
|
||||
private function errorMessage(Response $res, string $fallback): string
|
||||
{
|
||||
$message = $res->json('error') ?? $res->json('message');
|
||||
if (is_array($message)) {
|
||||
$message = collect($message)->flatten()->first();
|
||||
}
|
||||
|
||||
$message = trim((string) ($message ?? ''));
|
||||
|
||||
return $message !== '' ? $message : $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
+17
-4
@@ -159,6 +159,7 @@ function mobileKeyboardBottomOffset() {
|
||||
|
||||
Alpine.data('miniPaymentLanding', (config = {}) => ({
|
||||
amount: config.amount ?? '',
|
||||
phone: config.phone ?? '',
|
||||
loading: false,
|
||||
errorMsg: config.errorMsg ?? '',
|
||||
showSheet: false,
|
||||
@@ -214,6 +215,12 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
const phone = String(this.phone || '').trim();
|
||||
if (!phone) {
|
||||
this.errorMsg = 'Enter your MoMo number to pay.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMsg = '';
|
||||
this.loading = true;
|
||||
|
||||
@@ -226,11 +233,14 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
|
||||
'X-CSRF-TOKEN': config.csrf,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify({ amount: value }),
|
||||
body: JSON.stringify({ amount: value, customer_phone: phone }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.error || data.message) {
|
||||
this.errorMsg = data.error || data.message || 'Could not start payment. Please try again.';
|
||||
const apiError = typeof data.error === 'string'
|
||||
? data.error
|
||||
: (typeof data.message === 'string' ? data.message : '');
|
||||
if (!res.ok || apiError) {
|
||||
this.errorMsg = apiError || 'Could not start payment. Please try again.';
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
@@ -239,7 +249,10 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
if (window.innerWidth < 768) {
|
||||
|
||||
// MoMo waiting page must be full-page (prompt + status poll), not the Paystack iframe sheet.
|
||||
const useSheet = data.provider !== 'mtn_momo' && window.innerWidth < 768;
|
||||
if (useSheet) {
|
||||
this.checkoutUrl = data.checkout_url;
|
||||
this.showSheet = true;
|
||||
this.loading = false;
|
||||
|
||||
@@ -358,12 +358,13 @@
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) { this.errorMsg = data.error; this.loading = false; return; }
|
||||
if (window.innerWidth < 768) {
|
||||
if (!data.checkout_url) { this.errorMsg = 'Could not start payment.'; this.loading = false; return; }
|
||||
if (data.provider === 'mtn_momo' || window.innerWidth >= 768) {
|
||||
window.location.href = data.checkout_url;
|
||||
} else {
|
||||
this.checkoutUrl = data.checkout_url;
|
||||
this.showSheet = true;
|
||||
this.loading = false;
|
||||
} else {
|
||||
window.location.href = data.checkout_url;
|
||||
}
|
||||
} catch(e) {
|
||||
this.errorMsg = 'Network error. Please try again.';
|
||||
@@ -671,8 +672,11 @@
|
||||
const data = await res.json();
|
||||
if (data.error) { this.errorMsg = data.error; this.loading = false; return; }
|
||||
if (!data.paid) { window.location.href = data.success_url; return; }
|
||||
if (window.innerWidth < 768) { this.checkoutUrl = data.checkout_url; this.showSheet = true; this.loading = false; }
|
||||
else { window.location.href = data.checkout_url; }
|
||||
if (data.provider === 'mtn_momo' || window.innerWidth >= 768) {
|
||||
window.location.href = data.checkout_url;
|
||||
} else {
|
||||
this.checkoutUrl = data.checkout_url; this.showSheet = true; this.loading = false;
|
||||
}
|
||||
} catch(e) { this.errorMsg = 'Network error. Please try again.'; this.loading = false; }
|
||||
}
|
||||
}" class="min-h-screen bg-slate-50 pb-12">
|
||||
@@ -1205,12 +1209,13 @@
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) { this.errorMsg = data.error; this.loading = false; return; }
|
||||
if (window.innerWidth < 768) {
|
||||
if (!data.checkout_url) { this.errorMsg = 'Could not start payment.'; this.loading = false; return; }
|
||||
if (data.provider === 'mtn_momo' || window.innerWidth >= 768) {
|
||||
window.location.href = data.checkout_url;
|
||||
} else {
|
||||
this.checkoutUrl = data.checkout_url;
|
||||
this.showSheet = true;
|
||||
this.loading = false;
|
||||
} else {
|
||||
window.location.href = data.checkout_url;
|
||||
}
|
||||
} catch(e) {
|
||||
this.errorMsg = 'Network error. Please try again.';
|
||||
|
||||
@@ -21,13 +21,14 @@
|
||||
payUrl: @js($payUrl),
|
||||
csrf: @js($csrf),
|
||||
amount: @js(old('amount')),
|
||||
phone: @js(old('customer_phone')),
|
||||
errorMsg: @js(session('error')),
|
||||
})">
|
||||
|
||||
{{-- Mobile: Ladill Mini branding + fixed payment sheet --}}
|
||||
<div class="md:hidden">
|
||||
<div class="fixed inset-0 overflow-hidden bg-slate-50">
|
||||
<div class="flex h-full flex-col items-center justify-center px-6 pb-56 text-center pt-[max(2.5rem,env(safe-area-inset-top))]">
|
||||
<div class="flex h-full flex-col items-center justify-center px-6 pb-72 text-center pt-[max(2.5rem,env(safe-area-inset-top))]">
|
||||
<img src="{{ asset('images/launcher-icons/mini.svg') }}?v={{ @filemtime(public_path('images/launcher-icons/mini.svg')) ?: '1' }}"
|
||||
alt="Ladill Mini" class="h-14 w-14 object-contain">
|
||||
<h1 class="mt-4 text-xl font-bold text-slate-900">{{ $businessName }}</h1>
|
||||
@@ -57,6 +58,13 @@
|
||||
placeholder="0.00">
|
||||
</div>
|
||||
|
||||
<label for="phone-mobile" class="mt-3 block text-xs font-medium text-slate-500">MTN MoMo number</label>
|
||||
<input type="tel" id="phone-mobile" x-model="phone" required
|
||||
inputmode="tel" autocomplete="tel"
|
||||
@keydown.enter.prevent="submitPay()"
|
||||
class="mt-1.5 w-full rounded-2xl border-slate-200 py-3.5 px-4 text-base font-medium text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
|
||||
placeholder="e.g. 024XXXXXXX">
|
||||
|
||||
<button type="button" @click="submitPay()" :disabled="loading"
|
||||
class="mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-indigo-600 py-4 text-base font-semibold text-white hover:bg-indigo-700 disabled:opacity-60">
|
||||
<svg x-show="loading" x-cloak class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
@@ -66,7 +74,7 @@
|
||||
<span x-text="loading ? 'Opening payment…' : 'Pay'">Pay</span>
|
||||
</button>
|
||||
|
||||
<p class="mt-3 text-center text-[11px] text-slate-400">Secured by Paystack. Powered by Ladill Pay</p>
|
||||
<p class="mt-3 text-center text-[11px] text-slate-400">Pay with MTN MoMo. Powered by Ladill Pay</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,6 +103,14 @@
|
||||
class="w-full rounded-2xl border-slate-200 py-4 pl-16 pr-4 text-3xl font-bold tracking-tight text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
|
||||
placeholder="0.00">
|
||||
</div>
|
||||
|
||||
<label for="phone-desktop" class="mt-3 block text-xs font-medium text-slate-500">MTN MoMo number</label>
|
||||
<input type="tel" id="phone-desktop" x-model="phone" required
|
||||
inputmode="tel" autocomplete="tel"
|
||||
@keydown.enter.prevent="submitPay()"
|
||||
class="mt-1.5 w-full rounded-2xl border-slate-200 py-3.5 px-4 text-base font-medium text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
|
||||
placeholder="e.g. 024XXXXXXX">
|
||||
|
||||
<button type="button" @click="submitPay()" :disabled="loading"
|
||||
class="mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-indigo-600 py-4 text-base font-semibold text-white hover:bg-indigo-700 disabled:opacity-60">
|
||||
<svg x-show="loading" x-cloak class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
@@ -105,7 +121,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-center text-[11px] text-slate-400">Secured by Paystack. Powered by Ladill Pay</p>
|
||||
<p class="mt-4 text-center text-[11px] text-slate-400">Pay with MTN MoMo. Powered by Ladill Pay</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user