From 3c0172f098130696f8cd84124c16a5fc27d6252b Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 15 Jul 2026 08:29:07 +0000 Subject: [PATCH] Embed POS checkouts in a modal/sheet using the seller email. Card/MoMo always initializes with the merchant email, and Paystack/Flutterwave/Hubtel open in-page (desktop modal, mobile bottomsheet) instead of a full redirect. Co-authored-by: Cursor --- .../Controllers/Pos/RegisterController.php | 12 ++- app/Http/Controllers/Pos/SaleController.php | 24 ++++-- app/Http/Controllers/Pos/TicketController.php | 37 +++++++-- app/Services/Pos/PosSaleService.php | 4 +- resources/js/app.js | 26 +++++-- .../views/components/app-layout.blade.php | 1 + .../partials/payment-checkout-host.blade.php | 16 ++++ .../views/partials/paystack-sheet.blade.php | 78 +++++++++++++++++++ resources/views/pos/payment-return.blade.php | 27 +++++++ tests/Feature/PosRegisterTest.php | 40 +++++++--- 10 files changed, 230 insertions(+), 35 deletions(-) create mode 100644 resources/views/partials/payment-checkout-host.blade.php create mode 100644 resources/views/partials/paystack-sheet.blade.php create mode 100644 resources/views/pos/payment-return.blade.php diff --git a/app/Http/Controllers/Pos/RegisterController.php b/app/Http/Controllers/Pos/RegisterController.php index e9aba4a..a851dc8 100644 --- a/app/Http/Controllers/Pos/RegisterController.php +++ b/app/Http/Controllers/Pos/RegisterController.php @@ -176,7 +176,17 @@ class RegisterController extends Controller $result = $this->sales->initiatePayCheckout($sale, $merchant); - return redirect()->away($result['checkout_url']); + if ($request->expectsJson() || $request->ajax()) { + return response()->json([ + 'checkout_url' => $result['checkout_url'], + 'sale_id' => $sale->id, + ]); + } + + return redirect() + ->route('pos.sales.show', $sale) + ->with('checkout_url', $result['checkout_url']) + ->with('success', 'Complete the payment to finish this sale.'); } catch (RuntimeException $e) { return back()->withInput()->with('error', $e->getMessage()); } diff --git a/app/Http/Controllers/Pos/SaleController.php b/app/Http/Controllers/Pos/SaleController.php index 98c5e6a..6bda6b3 100644 --- a/app/Http/Controllers/Pos/SaleController.php +++ b/app/Http/Controllers/Pos/SaleController.php @@ -82,22 +82,34 @@ class SaleController extends Controller return redirect()->route('pos.sales.index')->with('success', 'Sale deleted.'); } - public function callback(Request $request, PosSale $sale): RedirectResponse + public function callback(Request $request, PosSale $sale): RedirectResponse|View { $reference = trim((string) $request->query('reference', $sale->payment_reference ?? '')); if ($reference === '') { - return redirect()->route('pos.sales.show', $sale)->with('error', 'Missing payment reference.'); + return $this->paymentReturn(route('pos.sales.show', $sale), error: 'Missing payment reference.'); } try { $sale = $this->sales->completePayCheckout($reference); } catch (RuntimeException $e) { - return redirect()->route('pos.sales.show', $sale)->with('error', $e->getMessage()); + return $this->paymentReturn(route('pos.sales.show', $sale), error: $e->getMessage()); } - return redirect() - ->route('pos.sales.show', $sale) - ->with('success', 'Payment received.'); + return $this->paymentReturn(route('pos.sales.show', $sale), success: 'Payment received.'); + } + + private function paymentReturn(string $url, ?string $success = null, ?string $error = null): View + { + if ($success) { + session()->flash('success', $success); + } + if ($error) { + session()->flash('error', $error); + } + + return view('pos.payment-return', [ + 'redirect' => $url, + ]); } } diff --git a/app/Http/Controllers/Pos/TicketController.php b/app/Http/Controllers/Pos/TicketController.php index 8ab4835..e9e4388 100644 --- a/app/Http/Controllers/Pos/TicketController.php +++ b/app/Http/Controllers/Pos/TicketController.php @@ -227,30 +227,53 @@ class TicketController extends Controller $result = $this->sales->startPayPayment($sale, $merchant, $amountMinor); - return redirect()->away($result['checkout_url']); + if ($request->expectsJson() || $request->ajax()) { + return response()->json([ + 'checkout_url' => $result['checkout_url'], + 'sale_id' => $sale->id, + ]); + } + + return redirect() + ->route('pos.tickets.show', $sale) + ->with('checkout_url', $result['checkout_url']) + ->with('success', 'Complete the payment to settle this ticket.'); } catch (RuntimeException $e) { return back()->with('error', $e->getMessage()); } } - public function paymentCallback(Request $request): RedirectResponse + public function paymentCallback(Request $request): RedirectResponse|View { $reference = trim((string) $request->query('reference', '')); if ($reference === '') { - return redirect()->route('pos.floor')->with('error', 'Missing payment reference.'); + return view('pos.payment-return', [ + 'redirect' => route('pos.floor'), + ]); } try { $payment = $this->sales->completePayPayment($reference); } catch (\Throwable) { - return redirect()->route('pos.floor')->with('error', 'Payment could not be verified.'); + session()->flash('error', 'Payment could not be verified.'); + + return view('pos.payment-return', [ + 'redirect' => route('pos.floor'), + ]); } $sale = $payment->sale->fresh(); + if ($sale->isOpen()) { + session()->flash('success', 'Payment recorded — balance '.$sale->currency.' '.number_format($sale->balanceMinor() / 100, 2)); + $url = route('pos.tickets.show', $sale); + } else { + session()->flash('success', 'Ticket settled.'); + $url = route('pos.sales.show', $sale); + } - return $sale->isOpen() - ? redirect()->route('pos.tickets.show', $sale)->with('success', 'Payment received — balance remaining.') - : redirect()->route('pos.sales.show', $sale)->with('success', 'Payment received.'); + return view('pos.payment-return', [ + 'redirect' => $url, + ]); } private function authorizeOpen(Request $request, PosSale $sale): void diff --git a/app/Services/Pos/PosSaleService.php b/app/Services/Pos/PosSaleService.php index 80d1a4a..064c768 100644 --- a/app/Services/Pos/PosSaleService.php +++ b/app/Services/Pos/PosSaleService.php @@ -305,7 +305,7 @@ class PosSaleService $merchant, $amount, (string) ($sale->currency ?? 'GHS'), - (string) ($sale->customer_email ?: $merchant->email ?: 'payer@example.com'), + (string) (trim((string) ($merchant->email ?? '')) !== '' ? $merchant->email : ('seller+'.($merchant->public_id ?? 'pos').'@checkout.ladill.local')), route('pos.payments.callback'), $reference, ['title' => 'Ticket '.$sale->reference.' payment', 'pos_sale_id' => $sale->id, 'pos_payment_id' => $payment->id], @@ -506,7 +506,7 @@ class PosSaleService $merchant, $amount, (string) ($sale->currency ?? 'GHS'), - (string) ($sale->customer_email ?: $merchant->email ?: 'payer@example.com'), + (string) (trim((string) ($merchant->email ?? '')) !== '' ? $merchant->email : ('seller+'.($merchant->public_id ?? 'pos').'@checkout.ladill.local')), $callbackUrl, $reference, ['title' => 'POS '.$sale->reference, 'pos_sale_id' => $sale->id, 'pos_reference' => $sale->reference], diff --git a/resources/js/app.js b/resources/js/app.js index df1502b..9fe7a59 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -16,6 +16,22 @@ import qrcode from 'qrcode-generator'; window.qrcode = qrcode; Alpine.plugin(collapse); + +document.addEventListener('alpine:init', () => { + Alpine.store('paymentCheckout', { + isOpen: false, + url: '', + open(url) { + this.url = url || ''; + this.isOpen = this.url !== ''; + }, + close() { + this.isOpen = false; + this.url = ''; + }, + }); +}); + registerLadillClipboard(Alpine); Alpine.data('notificationDropdown', (config = {}) => ({ @@ -239,13 +255,9 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({ this.loading = false; return; } - if (window.innerWidth < 768) { - this.checkoutUrl = data.checkout_url; - this.showSheet = true; - this.loading = false; - } else { - window.location.href = data.checkout_url; - } + this.checkoutUrl = data.checkout_url; + this.showSheet = true; + this.loading = false; } catch (e) { this.errorMsg = 'Network error. Please try again.'; this.loading = false; diff --git a/resources/views/components/app-layout.blade.php b/resources/views/components/app-layout.blade.php index d93ea6e..70b1c52 100644 --- a/resources/views/components/app-layout.blade.php +++ b/resources/views/components/app-layout.blade.php @@ -56,5 +56,6 @@ @endauth @include('partials.afia') @include('partials.confirm-prompt') + @include('partials.payment-checkout-host') diff --git a/resources/views/partials/payment-checkout-host.blade.php b/resources/views/partials/payment-checkout-host.blade.php new file mode 100644 index 0000000..1e2c827 --- /dev/null +++ b/resources/views/partials/payment-checkout-host.blade.php @@ -0,0 +1,16 @@ +
+
+ @include('partials.paystack-sheet') +
+
diff --git a/resources/views/partials/paystack-sheet.blade.php b/resources/views/partials/paystack-sheet.blade.php new file mode 100644 index 0000000..eed268f --- /dev/null +++ b/resources/views/partials/paystack-sheet.blade.php @@ -0,0 +1,78 @@ +{{-- + Payment checkout shell: bottomsheet on mobile, centered modal on desktop. + Requires Alpine ancestor with: showSheet (bool), checkoutUrl (string). +--}} + diff --git a/resources/views/pos/payment-return.blade.php b/resources/views/pos/payment-return.blade.php new file mode 100644 index 0000000..e57b6ad --- /dev/null +++ b/resources/views/pos/payment-return.blade.php @@ -0,0 +1,27 @@ + + + + + + Payment complete + + + +

Finishing payment…

+ + + diff --git a/tests/Feature/PosRegisterTest.php b/tests/Feature/PosRegisterTest.php index 491229e..32809d2 100644 --- a/tests/Feature/PosRegisterTest.php +++ b/tests/Feature/PosRegisterTest.php @@ -7,7 +7,6 @@ use App\Models\PosLocation; use App\Models\PosProduct; use App\Models\PosSale; use App\Models\User; -use App\Services\Pay\PayClient; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; use Tests\TestCase; @@ -122,27 +121,44 @@ class PosRegisterTest extends TestCase $this->assertSame(2000, $sale->total_minor); } - public function test_pay_sale_redirects_to_checkout(): void + public function test_pay_sale_opens_embedded_checkout(): void { $user = $this->user(); - $this->mock(PayClient::class, function ($mock) { - $mock->shouldReceive('createCheckout')->once()->andReturn([ - 'id' => 99, - 'reference' => 'LP-TESTREF', - 'checkout_url' => 'https://checkout.paystack.com/test', - ]); - }); + \App\Models\PaymentGatewaySetting::create([ + 'owner_ref' => $user->public_id, + 'provider' => \App\Models\PaymentGatewaySetting::PROVIDER_PAYSTACK, + 'public_key' => 'pk_test', + 'secret_key' => 'sk_test', + 'is_active' => true, + ]); - $this->actingAs($user)->post(route('pos.register.charge'), [ + Http::fake([ + 'crm.test/api/customers*' => Http::response(['data' => []], 200), + 'https://api.paystack.co/transaction/initialize' => Http::response([ + 'status' => true, + 'data' => [ + 'authorization_url' => 'https://checkout.paystack.com/test', + 'reference' => 'POSS-TESTREF', + ], + ], 200), + ]); + + $response = $this->actingAs($user)->post(route('pos.register.charge'), [ 'payment_method' => 'pay', 'lines' => [ ['name' => 'Snack', 'unit_price_minor' => 500, 'quantity' => 1], ], - ])->assertRedirect('https://checkout.paystack.com/test'); + ]); $sale = PosSale::where('owner_ref', $user->public_id)->first(); - $this->assertSame('LP-TESTREF', $sale->payment_reference); + $this->assertNotNull($sale); + $response->assertRedirect(route('pos.sales.show', $sale)); + $response->assertSessionHas('checkout_url', 'https://checkout.paystack.com/test'); $this->assertSame(PosSale::STATUS_PENDING, $sale->status); + $this->assertNotEmpty($sale->payment_reference); + + Http::assertSent(fn ($r) => str_contains($r->url(), 'paystack.co') + && ($r['email'] ?? null) === $user->email); } }