diff --git a/app/Http/Controllers/Public/PaymentController.php b/app/Http/Controllers/Public/PaymentController.php index 8877098..6acc46b 100644 --- a/app/Http/Controllers/Public/PaymentController.php +++ b/app/Http/Controllers/Public/PaymentController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Public; use App\Http\Controllers\Controller; +use App\Models\MiniPayment; use App\Models\QrCode; use App\Services\Mini\MiniPaymentService; use App\Support\LadillLink; @@ -50,19 +51,40 @@ class PaymentController extends Controller return redirect()->away($result['checkout_url']); } - public function callback(Request $request, string $shortCode): RedirectResponse|View + public function callback(Request $request, string $shortCode): View { $reference = trim((string) $request->query('reference', '')); if ($reference === '') { - return redirect()->away(LadillLink::url($shortCode))->with('error', 'Missing payment reference.'); + return view('public.payment-return', [ + 'redirect' => LadillLink::url($shortCode), + ]); } try { $payment = $this->payments->complete($reference); } catch (RuntimeException $e) { - return redirect()->away(LadillLink::url($shortCode))->with('error', $e->getMessage()); + return view('public.payment-return', [ + 'redirect' => LadillLink::url($shortCode), + ]); } + return view('public.payment-return', [ + 'redirect' => route('qr.public.payment.confirmed', [ + 'shortCode' => $shortCode, + 'reference' => $payment->payment_reference, + ], absolute: true), + ]); + } + + public function confirmed(string $shortCode, string $reference): View + { + $payment = MiniPayment::query() + ->with('qrCode') + ->where('payment_reference', $reference) + ->where('status', MiniPayment::STATUS_PAID) + ->whereHas('qrCode', fn ($q) => $q->where('short_code', $shortCode)) + ->firstOrFail(); + return view('public.qr.payment-confirmed', [ 'payment' => $payment, 'qrCode' => $payment->qrCode, diff --git a/resources/js/app.js b/resources/js/app.js index dea4856..e2def61 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -248,7 +248,7 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({ // 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' && window.innerWidth < 768; + const useSheet = data.provider !== 'mtn_momo'; if (useSheet) { this.checkoutUrl = data.checkout_url; this.showSheet = true; diff --git a/resources/views/components/user/service-topup-modal.blade.php b/resources/views/components/user/service-topup-modal.blade.php index f9a5675..acdbd94 100644 --- a/resources/views/components/user/service-topup-modal.blade.php +++ b/resources/views/components/user/service-topup-modal.blade.php @@ -36,7 +36,7 @@ loading: false, errorMsg: '', async submitTopup(event) { - if (this.method !== 'paystack' || window.innerWidth >= 768) return; + if (this.method !== 'paystack') return; event.preventDefault(); this.loading = true; diff --git a/resources/views/partials/paystack-sheet.blade.php b/resources/views/partials/paystack-sheet.blade.php index ce2f127..9d0c4a4 100644 --- a/resources/views/partials/paystack-sheet.blade.php +++ b/resources/views/partials/paystack-sheet.blade.php @@ -1,44 +1,147 @@ {{-- - Paystack mobile bottom-sheet iframe. - Requires Alpine.js ancestor with: showSheet (bool), checkoutUrl (string). - On mobile (< md) the sheet slides up; on desktop nothing renders. + Responsive payment checkout shell: bottom sheet on mobile, centered modal on desktop. + + Audiences: + - buyer (default): public ticket buyers, donors, shoppers — self-serve checkout + - operator: signed-in staff flows such as wallet top-up + + Optional overrides: $sheetTitle, $sheetSubtitle, $sheetFooter, $sheetAria, $iframeTitle + Requires Alpine ancestor with: showSheet (bool), checkoutUrl (string). + + Loads the provider authorization URL in-frame (verified via production Events/POS). + MoMo and other non-frameable providers should redirect instead of opening this sheet. --}} +@php + $audience = $audience ?? 'buyer'; + $defaults = match ($audience) { + 'operator' => [ + 'title' => 'Complete payment', + 'subtitle' => null, + 'aria' => 'Complete payment', + 'iframe' => 'Payment checkout', + 'footer' => null, + ], + default => [ + 'title' => 'Complete your payment', + 'subtitle' => 'Pay securely. You\'ll get a confirmation when it succeeds.', + 'aria' => 'Complete your payment', + 'iframe' => 'Secure payment checkout', + 'footer' => 'Your payment is processed securely. Do not close this window until finished.', + ], + }; + $sheetTitle = $sheetTitle ?? $defaults['title']; + $sheetSubtitle = $sheetSubtitle ?? $defaults['subtitle']; + $sheetAria = $sheetAria ?? $defaults['aria']; + $iframeTitle = $iframeTitle ?? $defaults['iframe']; + $sheetFooter = $sheetFooter ?? $defaults['footer']; +@endphp diff --git a/resources/views/public/payment-return.blade.php b/resources/views/public/payment-return.blade.php new file mode 100644 index 0000000..82ed789 --- /dev/null +++ b/resources/views/public/payment-return.blade.php @@ -0,0 +1,27 @@ + + + + + + Payment + + + +

Confirming your payment…

+ + + diff --git a/resources/views/public/qr/landing.blade.php b/resources/views/public/qr/landing.blade.php index aafe55c..363ab12 100644 --- a/resources/views/public/qr/landing.blade.php +++ b/resources/views/public/qr/landing.blade.php @@ -359,7 +359,7 @@ const data = await res.json(); if (data.error) { 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.innerWidth >= 768) { + if (data.provider === 'mtn_momo') { window.location.href = data.checkout_url; } else { this.checkoutUrl = data.checkout_url; @@ -474,7 +474,7 @@ {{-- Header --}}

Give Online

-

Secured by Paystack. Powered by Ladill Pay

+

Your payment is processed securely

@@ -672,7 +672,7 @@ 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 (data.provider === 'mtn_momo' || window.innerWidth >= 768) { + if (data.provider === 'mtn_momo') { window.location.href = data.checkout_url; } else { this.checkoutUrl = data.checkout_url; this.showSheet = true; this.loading = false; @@ -1210,7 +1210,7 @@ const data = await res.json(); if (data.error) { 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.innerWidth >= 768) { + if (data.provider === 'mtn_momo') { window.location.href = data.checkout_url; } else { this.checkoutUrl = data.checkout_url; @@ -1476,7 +1476,7 @@ -

Secured by Paystack. Powered by Ladill Pay

+

Your payment is processed securely

diff --git a/resources/views/public/qr/payment-landing.blade.php b/resources/views/public/qr/payment-landing.blade.php index 68b4c69..8c2ff71 100644 --- a/resources/views/public/qr/payment-landing.blade.php +++ b/resources/views/public/qr/payment-landing.blade.php @@ -73,7 +73,7 @@ -

Secured by Paystack. Powered by Ladill Pay

+

Your payment is processed securely

@@ -116,7 +116,7 @@ -

Secured by Paystack. Powered by Ladill Pay

+

Your payment is processed securely

@@ -193,7 +193,7 @@ return; } // Legacy MoMo waiting page must be full-page — never trap it in the Paystack iframe. - const useSheet = data.provider !== 'mtn_momo' && window.innerWidth < 768; + const useSheet = data.provider !== 'mtn_momo'; if (useSheet) { this.checkoutUrl = data.checkout_url; this.showSheet = true; diff --git a/routes/web.php b/routes/web.php index 10c762d..640e193 100644 --- a/routes/web.php +++ b/routes/web.php @@ -36,6 +36,7 @@ Route::get('/q/{shortCode}', [QrScanController::class, 'resolve'])->name('qr.pub Route::get('/q/{shortCode}/payment-logo', [QrScanController::class, 'paymentLogo'])->name('qr.public.payment.logo'); Route::post('/q/{shortCode}/pay', [PaymentController::class, 'pay'])->name('qr.public.payment.pay'); Route::get('/q/{shortCode}/pay/callback', [PaymentController::class, 'callback'])->name('qr.public.payment.callback'); +Route::get('/q/{shortCode}/pay/confirmed/{reference}', [PaymentController::class, 'confirmed'])->name('qr.public.payment.confirmed'); }); Route::middleware(['auth', 'platform.session'])->group(function () { diff --git a/tests/Feature/MiniPaymentCheckoutTest.php b/tests/Feature/MiniPaymentCheckoutTest.php index 47925dc..cf2d26e 100644 --- a/tests/Feature/MiniPaymentCheckoutTest.php +++ b/tests/Feature/MiniPaymentCheckoutTest.php @@ -14,7 +14,7 @@ class MiniPaymentCheckoutTest extends TestCase { use RefreshDatabase; - public function test_payment_landing_shows_paystack_branding_not_momo(): void + public function test_payment_landing_shows_secure_checkout_not_momo(): void { $user = User::create([ 'public_id' => (string) Str::uuid(), @@ -40,9 +40,12 @@ class MiniPaymentCheckoutTest extends TestCase $this->withHeader(\App\Support\LadillLink::INTERNAL_HEADER, '1') ->get('/q/'.$qr->short_code) ->assertOk() - ->assertSee('Secured by Paystack. Powered by Ladill Pay', false) + ->assertSee('Your payment is processed securely', false) + ->assertSee('md:items-center', false) + ->assertSee('Complete your payment', false) ->assertDontSee('MTN MoMo number', false) - ->assertDontSee('Pay with MTN MoMo', false); + ->assertDontSee('Pay with MTN MoMo', false) + ->assertDontSee('Paystack checkout', false); } public function test_pay_initiation_does_not_require_phone_and_returns_paystack_checkout(): void @@ -101,4 +104,34 @@ class MiniPaymentCheckoutTest extends TestCase 'payer_phone' => null, ]); } + + public function test_payment_callback_returns_top_frame_escape(): void + { + $user = User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Vendor', + 'email' => 'vendor3@example.com', + ]); + + $qr = QrCode::query()->create([ + 'user_id' => $user->id, + 'short_code' => 'paycb001', + 'type' => QrCode::TYPE_PAYMENT, + 'label' => 'Till', + 'payload' => [ + 'content' => [ + 'business_name' => 'Accra Kiosk', + 'currency' => 'GHS', + ], + 'style' => [], + ], + 'is_active' => true, + ]); + + $this->withHeader(\App\Support\LadillLink::INTERNAL_HEADER, '1') + ->get('/q/'.$qr->short_code.'/pay/callback') + ->assertOk() + ->assertSee('Confirming your payment', false) + ->assertSee('window.top.location.replace', false); + } }