diff --git a/app/Http/Controllers/Public/PaymentController.php b/app/Http/Controllers/Public/PaymentController.php index d17f22f..20af88e 100644 --- a/app/Http/Controllers/Public/PaymentController.php +++ b/app/Http/Controllers/Public/PaymentController.php @@ -41,7 +41,7 @@ class PaymentController extends Controller return back()->withInput()->with('error', $e->getMessage()); } - if ($request->expectsJson()) { + if ($request->expectsJson() || $request->ajax()) { return response()->json([ 'checkout_url' => $result['checkout_url'], 'access_code' => $result['access_code'] ?? null, @@ -52,7 +52,24 @@ class PaymentController extends Controller ]); } - return redirect()->away($result['checkout_url']); + // Never send the browser to checkout.paystack.com — reopen the landing + // with checkout payload so Paystack Inline can run in-page. + $provider = (string) ($result['provider'] ?? ''); + if ($provider === 'mtn_momo') { + $waiting = (string) ($result['checkout_url'] ?? ''); + if ($waiting !== '' && str_starts_with($waiting, url('/'))) { + return redirect()->to($waiting); + } + } + + return redirect() + ->to(url('/q/'.$shortCode)) + ->with('checkout_url', $result['checkout_url'] ?? null) + ->with('access_code', $result['access_code'] ?? null) + ->with('public_key', $result['public_key'] ?? null) + ->with('callback_url', $result['callback_url'] ?? null) + ->with('provider', $provider !== '' ? $provider : 'paystack') + ->with('amount', $validated['amount']); } public function callback(Request $request, string $shortCode): View diff --git a/app/Http/Middleware/RedirectLegacyQrToLadillLink.php b/app/Http/Middleware/RedirectLegacyQrToLadillLink.php index 292a3de..ddb85d7 100644 --- a/app/Http/Middleware/RedirectLegacyQrToLadillLink.php +++ b/app/Http/Middleware/RedirectLegacyQrToLadillLink.php @@ -19,13 +19,20 @@ class RedirectLegacyQrToLadillLink return $next($request); } - // Same-origin AJAX from Mini-hosted payment pages (e.g. mini.ladill.com) - // must hit local /q/{code}/pay — redirecting to ladl.link breaks CORS and - // the checkout sheet never opens. + // Same-origin pay from Mini-hosted pages (e.g. mini.ladill.com) must hit + // local /q/{code}/pay — redirecting to ladl.link breaks CORS / form POSTs + // and used to dump the browser onto checkout.paystack.com. if ($request->ajax() || $request->expectsJson() || $request->wantsJson()) { return $next($request); } + // Form POST (and any method) to the Mini pay endpoint stays on Mini so + // PaymentController can return JSON or flash payload for in-page Inline. + $path = ltrim($request->path(), '/'); + if (preg_match('#^q/[^/]+/pay(?:/callback)?$#i', $path) === 1) { + return $next($request); + } + return LadillLink::legacyRedirect($request); } } diff --git a/resources/js/app.js b/resources/js/app.js index e3e8576..208402a 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -201,6 +201,17 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({ } this._syncSheet(); + + // Non-JS form POST fallback flashes checkout payload — open Inline in-page. + if (config.bootstrapCheckoutUrl || config.bootstrapAccessCode) { + this.openInlineCheckout({ + checkout_url: config.bootstrapCheckoutUrl || '', + access_code: config.bootstrapAccessCode || '', + public_key: config.bootstrapPublicKey || '', + callback_url: config.bootstrapCallbackUrl || '', + provider: config.bootstrapProvider || 'paystack', + }); + } }, destroy() { @@ -211,6 +222,30 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({ document.documentElement.classList.remove('mini-payment-page'); }, + isSameOrigin(url) { + if (!url) return false; + try { + return new URL(url, window.location.href).origin === window.location.origin; + } catch (e) { + return false; + } + }, + + openInlineCheckout(data = {}) { + // Never navigate to checkout.paystack.com. Same-origin MoMo waiting can navigate. + if (data.provider === 'mtn_momo' && this.isSameOrigin(data.checkout_url)) { + window.location.assign(data.checkout_url); + return false; + } + + this.checkoutUrl = data.checkout_url || ''; + this.accessCode = data.access_code || ''; + this.publicKey = data.public_key || ''; + this.returnUrl = data.callback_url || ''; + this.showSheet = true; + return true; + }, + async submitPay() { const value = parseFloat(this.amount); if (!value || value <= 0) { @@ -224,8 +259,8 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({ this.accessCode = ''; this.publicKey = ''; this.returnUrl = ''; - // Show sheet/modal immediately; Paystack Inline opens in-page from the sheet. - this.showSheet = true; + // Keep sheet closed until we have a checkout payload so Inline syncs once with access_code. + this.showSheet = false; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 45000); @@ -257,17 +292,7 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({ return; } - // Legacy MoMo waiting page must be full-page — never trap it in the Paystack iframe - // (cross-origin iframe is blank / looks like "Pay did nothing"). - const useSheet = data.provider !== 'mtn_momo'; - if (useSheet) { - this.checkoutUrl = data.checkout_url || ''; - this.accessCode = data.access_code || ''; - this.publicKey = data.public_key || ''; - this.returnUrl = data.callback_url || ''; - } else { - this.showSheet = false; - window.location.assign(data.checkout_url); + if (!this.openInlineCheckout(data)) { return; } } catch (e) { diff --git a/resources/views/partials/paystack-sheet.blade.php b/resources/views/partials/paystack-sheet.blade.php index 4aee647..e5dfd9a 100644 --- a/resources/views/partials/paystack-sheet.blade.php +++ b/resources/views/partials/paystack-sheet.blade.php @@ -187,12 +187,15 @@ this.launchInline(code, returnUrl); return; } - // Non-Paystack external URL without access_code — cannot embed. + // Never navigate to checkout.paystack.com (external tab/window). + // Without access_code we cannot open Inline in-page. this.frameable = false; this.inline = false; this.launching = false; this._activeCode = ''; - this.error = 'Secure checkout could not be opened in-page. Please try again.'; + this.error = isPaystackCheckoutUrl(url) + ? 'Could not start in-app payment. Please try again.' + : 'Secure checkout could not be opened in-page. Please try again.'; }, launchInline(accessCode, returnUrl) { var store = this; @@ -293,118 +296,124 @@ ); } " - @keydown.escape.window="if (showSheet) showSheet = false" + @keydown.escape.window="if (showSheet && $store.ladillPayCheckout && ($store.ladillPayCheckout.frameable || $store.ladillPayCheckout.error || $store.ladillPayCheckout.launching)) showSheet = false" @ladill-pay-cancelled.window="showSheet = false" - class="fixed inset-0 z-[9999] flex items-end justify-center md:items-center md:p-6" + class="fixed inset-0 z-[9999]" + :class="($store.ladillPayCheckout && $store.ladillPayCheckout.inline && !$store.ladillPayCheckout.launching && !$store.ladillPayCheckout.error && !$store.ladillPayCheckout.frameable) ? 'pointer-events-none' : ''" role="dialog" aria-modal="true" :aria-hidden="(!showSheet).toString()" aria-label="{{ $sheetAria }}"> -
+ x-transition:leave-end="opacity-0"> +
+ +

Opening payment…

+
- {{-- One panel: bottom sheet (mobile) + centered modal (desktop). --}} -
-
- -
-
-

{{ $sheetTitle }}

- @if (! empty($sheetSubtitle)) -

{{ $sheetSubtitle }}

- @endif -
- -
+ {{-- Full Ladill shell only for same-origin iframe (e.g. MoMo waiting) or recovery errors. --}} +
+
-
- -
-
diff --git a/resources/views/public/qr/landing.blade.php b/resources/views/public/qr/landing.blade.php index 6f5446e..5300dd2 100644 --- a/resources/views/public/qr/landing.blade.php +++ b/resources/views/public/qr/landing.blade.php @@ -362,15 +362,20 @@ const data = await res.json(); if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; return; } if (!data.checkout_url) { this.errorMsg = 'Could not start payment.'; this.loading = false; return; } - if (data.provider === 'mtn_momo') { - window.LadillPayCheckout?.cancel?.(); window.location.href = data.checkout_url; + // Never send the browser to checkout.paystack.com. + const isSameOrigin = (u) => { + try { return new URL(u, window.location.href).origin === window.location.origin; } catch (e) { return false; } + }; + if (data.provider === 'mtn_momo' && isSameOrigin(data.checkout_url)) { + window.LadillPayCheckout?.cancel?.(); + window.location.href = data.checkout_url; } else { - this.checkoutUrl = data.checkout_url; - this.accessCode = data.access_code || ''; - this.publicKey = data.public_key || ''; - this.returnUrl = data.callback_url || ''; - this.showSheet = true; - this.loading = false; + this.checkoutUrl = data.checkout_url || ''; + this.accessCode = data.access_code || ''; + this.publicKey = data.public_key || ''; + this.returnUrl = data.callback_url || ''; + this.showSheet = true; + this.loading = false; } } catch(e) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.'; @@ -678,13 +683,20 @@ const data = await res.json(); if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; return; } if (!data.paid) { window.location.href = data.success_url; return; } - if (data.provider === 'mtn_momo') { - window.LadillPayCheckout?.cancel?.(); window.location.href = data.checkout_url; + // Never send the browser to checkout.paystack.com. + const isSameOrigin = (u) => { + try { return new URL(u, window.location.href).origin === window.location.origin; } catch (e) { return false; } + }; + if (data.provider === 'mtn_momo' && isSameOrigin(data.checkout_url)) { + window.LadillPayCheckout?.cancel?.(); + window.location.href = data.checkout_url; } else { - this.checkoutUrl = data.checkout_url; - this.accessCode = data.access_code || ''; - this.publicKey = data.public_key || ''; - this.returnUrl = data.callback_url || ''; this.showSheet = true; this.loading = false; + this.checkoutUrl = data.checkout_url || ''; + this.accessCode = data.access_code || ''; + this.publicKey = data.public_key || ''; + this.returnUrl = data.callback_url || ''; + this.showSheet = true; + this.loading = false; } } catch(e) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.'; this.loading = false; } } @@ -1222,15 +1234,20 @@ const data = await res.json(); if (data.error) { window.LadillPayCheckout?.cancel?.(); this.errorMsg = data.error; this.loading = false; return; } if (!data.checkout_url) { this.errorMsg = 'Could not start payment.'; this.loading = false; return; } - if (data.provider === 'mtn_momo') { - window.LadillPayCheckout?.cancel?.(); window.location.href = data.checkout_url; + // Never send the browser to checkout.paystack.com. + const isSameOrigin = (u) => { + try { return new URL(u, window.location.href).origin === window.location.origin; } catch (e) { return false; } + }; + if (data.provider === 'mtn_momo' && isSameOrigin(data.checkout_url)) { + window.LadillPayCheckout?.cancel?.(); + window.location.href = data.checkout_url; } else { - this.checkoutUrl = data.checkout_url; - this.accessCode = data.access_code || ''; - this.publicKey = data.public_key || ''; - this.returnUrl = data.callback_url || ''; - this.showSheet = true; - this.loading = false; + this.checkoutUrl = data.checkout_url || ''; + this.accessCode = data.access_code || ''; + this.publicKey = data.public_key || ''; + this.returnUrl = data.callback_url || ''; + this.showSheet = true; + this.loading = false; } } catch(e) { window.LadillPayCheckout?.cancel?.(); 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 de2b6d8..26b119e 100644 --- a/resources/views/public/qr/payment-landing.blade.php +++ b/resources/views/public/qr/payment-landing.blade.php @@ -23,8 +23,13 @@ x-data="miniPaymentLanding({ payUrl: @js($payUrl), csrf: @js($csrf), - amount: @js(old('amount')), + amount: @js(old('amount', session('amount'))), errorMsg: @js(session('error')), + bootstrapCheckoutUrl: @js(session('checkout_url')), + bootstrapAccessCode: @js(session('access_code')), + bootstrapPublicKey: @js(session('public_key')), + bootstrapCallbackUrl: @js(session('callback_url')), + bootstrapProvider: @js(session('provider', 'paystack')), })"> {{-- Mobile: Ladill Mini branding + fixed payment sheet --}} @@ -123,112 +128,5 @@ @include('partials.paystack-sheet') - - {{-- - Safety net: if Alpine/Vite fails to boot, native form POST still works. - If Alpine boots but miniPaymentLanding was missing from an old bundle, redefine it. - --}} - diff --git a/tests/Feature/MiniPaymentCheckoutTest.php b/tests/Feature/MiniPaymentCheckoutTest.php index da72485..9c50346 100644 --- a/tests/Feature/MiniPaymentCheckoutTest.php +++ b/tests/Feature/MiniPaymentCheckoutTest.php @@ -44,13 +44,15 @@ class MiniPaymentCheckoutTest extends TestCase ->assertSee('md:items-center', false) ->assertSee('Complete your payment', false) ->assertSee('data-ladill-pay-sheet', false) + ->assertSee('data-ladill-pay-loading', false) ->assertSee('resumeTransaction', false) ->assertSee('/q/'.$qr->short_code.'/pay', false) ->assertDontSee('https://ladl.link/'.$qr->short_code.'/pay', false) ->assertDontSee('MTN MoMo number', false) ->assertDontSee('Pay with MTN MoMo', false) ->assertDontSee('Continue to Paystack', false) - ->assertDontSee('Paystack checkout', false); + ->assertDontSee('Paystack checkout', false) + ->assertDontSee('window.location.assign(data.checkout_url)', false); } public function test_json_pay_post_is_not_redirected_to_ladill_link(): void @@ -101,6 +103,55 @@ class MiniPaymentCheckoutTest extends TestCase ->assertJsonPath('provider', 'paystack'); } + public function test_html_pay_post_does_not_redirect_browser_to_paystack(): void + { + $user = User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Vendor', + 'email' => 'vendor-html@example.com', + ]); + + $qr = QrCode::query()->create([ + 'user_id' => $user->id, + 'short_code' => 'payhtml01', + 'type' => QrCode::TYPE_PAYMENT, + 'label' => 'Till', + 'payload' => [ + 'content' => [ + 'business_name' => 'Accra Kiosk', + 'currency' => 'GHS', + ], + 'style' => [], + ], + 'is_active' => true, + ]); + + $pay = Mockery::mock(PayClient::class); + $pay->shouldReceive('createCheckout') + ->once() + ->andReturn([ + 'id' => 101, + 'reference' => 'LP-TESTMINIHtml01', + 'checkout_url' => 'https://checkout.paystack.com/mini-html', + 'access_code' => 'mini-html', + 'public_key' => 'pk_test_x', + 'provider' => 'paystack', + 'platform_fee_minor' => 15, + 'merchant_amount_minor' => 985, + ]); + $this->app->instance(PayClient::class, $pay); + + // HTML form POST (no Accept: application/json) must stay on Mini and + // must not 307 to ladl.link or away to checkout.paystack.com. + $this->post('/q/'.$qr->short_code.'/pay', [ + 'amount' => 10, + ]) + ->assertRedirect(url('/q/'.$qr->short_code)) + ->assertSessionHas('checkout_url', 'https://checkout.paystack.com/mini-html') + ->assertSessionHas('access_code', 'mini-html') + ->assertSessionMissing('error'); + } + public function test_pay_initiation_does_not_require_phone_and_returns_paystack_checkout(): void { $user = User::create([ diff --git a/tests/Feature/ResponsivePaystackSheetTest.php b/tests/Feature/ResponsivePaystackSheetTest.php index 0db01ec..18da9d0 100644 --- a/tests/Feature/ResponsivePaystackSheetTest.php +++ b/tests/Feature/ResponsivePaystackSheetTest.php @@ -28,11 +28,13 @@ class ResponsivePaystackSheetTest extends TestCase $this->assertStringContainsString('LadillPayCheckout', $html); $this->assertStringContainsString('resumeTransaction', $html); $this->assertStringContainsString('js.paystack.co/v2/inline.js', $html); - $this->assertStringContainsString('Starting secure checkout', $html); - $this->assertStringContainsString('not in a separate browser', $html); + $this->assertStringContainsString('Opening payment', $html); + $this->assertStringContainsString('data-ladill-pay-loading', $html); + $this->assertStringContainsString('pointer-events-none', $html); // Must not push Paystack to a separate browser as the primary path. $this->assertStringNotContainsString('Continue to Paystack', $html); $this->assertStringNotContainsString('window.open(', $html); + $this->assertStringNotContainsString('Complete payment in the Paystack window', $html); // Must not use Tailwind `hidden` + x-show on the panel (desktop stays invisible). $this->assertStringNotContainsString('hidden w-full max-w-lg', $html); $this->assertStringNotContainsString('Paystack checkout', $html);