From 53a83b84c5ec80959d2bb5c787a60f5d93418b37 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Mon, 13 Jul 2026 22:52:52 +0000 Subject: [PATCH] Collect MoMo MSISDN and open waiting page on Pay. 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 --- .../Controllers/Public/PaymentController.php | 6 +++- app/Services/Mini/MiniPaymentService.php | 13 +++++-- app/Services/Pay/PayClient.php | 35 +++++++++++++++---- resources/js/app.js | 21 ++++++++--- resources/views/public/qr/landing.blade.php | 21 ++++++----- .../views/public/qr/payment-landing.blade.php | 22 ++++++++++-- 6 files changed, 92 insertions(+), 26 deletions(-) diff --git a/app/Http/Controllers/Public/PaymentController.php b/app/Http/Controllers/Public/PaymentController.php index 0035da7..311bea5 100644 --- a/app/Http/Controllers/Public/PaymentController.php +++ b/app/Http/Controllers/Public/PaymentController.php @@ -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']); diff --git a/app/Services/Mini/MiniPaymentService.php b/app/Services/Mini/MiniPaymentService.php index 5a72932..a0fa4a8 100644 --- a/app/Services/Mini/MiniPaymentService.php +++ b/app/Services/Mini/MiniPaymentService.php @@ -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'] ?? ''), ]; } diff --git a/app/Services/Pay/PayClient.php b/app/Services/Pay/PayClient.php index 19c94b5..06bd055 100644 --- a/app/Services/Pay/PayClient.php +++ b/app/Services/Pay/PayClient.php @@ -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 */ + 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; + } } diff --git a/resources/js/app.js b/resources/js/app.js index 60b7507..ad12e36 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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; diff --git a/resources/views/public/qr/landing.blade.php b/resources/views/public/qr/landing.blade.php index 236f109..aafe55c 100644 --- a/resources/views/public/qr/landing.blade.php +++ b/resources/views/public/qr/landing.blade.php @@ -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.'; diff --git a/resources/views/public/qr/payment-landing.blade.php b/resources/views/public/qr/payment-landing.blade.php index 9b5603e..4cd640e 100644 --- a/resources/views/public/qr/payment-landing.blade.php +++ b/resources/views/public/qr/payment-landing.blade.php @@ -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 --}}
-
+
Ladill Mini

{{ $businessName }}

@@ -57,6 +58,13 @@ placeholder="0.00">
+ + + -

Secured by Paystack. Powered by Ladill Pay

+

Pay with MTN MoMo. Powered by Ladill Pay

@@ -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">
+ + + + -

Secured by Paystack. Powered by Ladill Pay

+

Pay with MTN MoMo. Powered by Ladill Pay