Use Paystack/Ladill Pay on Mini payment landing.
Deploy Ladill Mini / deploy (push) Successful in 1m23s
Deploy Ladill Mini / deploy (push) Successful in 1m23s
Drop the required MTN MoMo number field and MoMo copy so public payment QRs open Paystack checkout via Ladill Pay like the other restored storefronts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -27,7 +27,7 @@ class PaymentController extends Controller
|
|||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'amount' => 'required|numeric|min:0.01',
|
'amount' => 'required|numeric|min:0.01',
|
||||||
'customer_phone' => 'required|string|max:32',
|
'customer_phone' => 'nullable|string|max:32',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ class MiniPaymentService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$customerPhone = trim((string) ($data['customer_phone'] ?? ''));
|
$customerPhone = trim((string) ($data['customer_phone'] ?? ''));
|
||||||
if ($customerPhone === '') {
|
|
||||||
throw new RuntimeException('Enter your MoMo number to pay.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$qrCode->loadMissing('user');
|
$qrCode->loadMissing('user');
|
||||||
$amountMinor = (int) round($amountGhs * 100);
|
$amountMinor = (int) round($amountGhs * 100);
|
||||||
@@ -57,19 +54,18 @@ class MiniPaymentService
|
|||||||
'currency' => $qrCode->content()['currency'] ?? 'GHS',
|
'currency' => $qrCode->content()['currency'] ?? 'GHS',
|
||||||
'payer_name' => null,
|
'payer_name' => null,
|
||||||
'payer_email' => null,
|
'payer_email' => null,
|
||||||
'payer_phone' => $customerPhone,
|
'payer_phone' => $customerPhone !== '' ? $customerPhone : null,
|
||||||
'payer_note' => null,
|
'payer_note' => null,
|
||||||
'status' => MiniPayment::STATUS_PENDING,
|
'status' => MiniPayment::STATUS_PENDING,
|
||||||
'payment_reference' => null,
|
'payment_reference' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$payOrder = $this->pay->createCheckout([
|
$checkout = [
|
||||||
'merchant' => $qrCode->user->public_id,
|
'merchant' => $qrCode->user->public_id,
|
||||||
'fee_tier' => 'payments',
|
'fee_tier' => 'payments',
|
||||||
'source_service' => 'mini',
|
'source_service' => 'mini',
|
||||||
'source_ref' => (string) $qrCode->id,
|
'source_ref' => (string) $qrCode->id,
|
||||||
'callback_url' => LadillLink::path($qrCode->short_code, 'pay/callback'),
|
'callback_url' => LadillLink::path($qrCode->short_code, 'pay/callback'),
|
||||||
'customer_phone' => $customerPhone,
|
|
||||||
'line_items' => [
|
'line_items' => [
|
||||||
[
|
[
|
||||||
'name' => $businessName,
|
'name' => $businessName,
|
||||||
@@ -82,7 +78,13 @@ class MiniPaymentService
|
|||||||
'mini_reference' => $reference,
|
'mini_reference' => $reference,
|
||||||
'qr_code_id' => $qrCode->id,
|
'qr_code_id' => $qrCode->id,
|
||||||
],
|
],
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
if ($customerPhone !== '') {
|
||||||
|
$checkout['customer_phone'] = $customerPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payOrder = $this->pay->createCheckout($checkout);
|
||||||
|
|
||||||
$payment->update([
|
$payment->update([
|
||||||
'pay_order_id' => $payOrder['id'] ?? null,
|
'pay_order_id' => $payOrder['id'] ?? null,
|
||||||
|
|||||||
+2
-9
@@ -160,7 +160,6 @@ function mobileKeyboardBottomOffset() {
|
|||||||
|
|
||||||
Alpine.data('miniPaymentLanding', (config = {}) => ({
|
Alpine.data('miniPaymentLanding', (config = {}) => ({
|
||||||
amount: config.amount ?? '',
|
amount: config.amount ?? '',
|
||||||
phone: config.phone ?? '',
|
|
||||||
loading: false,
|
loading: false,
|
||||||
errorMsg: config.errorMsg ?? '',
|
errorMsg: config.errorMsg ?? '',
|
||||||
showSheet: false,
|
showSheet: false,
|
||||||
@@ -216,12 +215,6 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const phone = String(this.phone || '').trim();
|
|
||||||
if (!phone) {
|
|
||||||
this.errorMsg = 'Enter your MoMo number to pay.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
@@ -237,7 +230,7 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
|
|||||||
'X-CSRF-TOKEN': config.csrf,
|
'X-CSRF-TOKEN': config.csrf,
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ amount: value, customer_phone: phone }),
|
body: JSON.stringify({ amount: value }),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
@@ -253,7 +246,7 @@ Alpine.data('miniPaymentLanding', (config = {}) => ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoMo waiting page must be full-page — never trap it in the Paystack iframe
|
// 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").
|
// (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' && window.innerWidth < 768;
|
||||||
if (useSheet) {
|
if (useSheet) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div class="max-w-xl">
|
<div class="max-w-xl">
|
||||||
<div class="inline-flex items-center gap-1.5 rounded-full bg-violet-100 px-3 py-1 text-xs font-semibold text-violet-700">
|
<div class="inline-flex items-center gap-1.5 rounded-full bg-violet-100 px-3 py-1 text-xs font-semibold text-violet-700">
|
||||||
MoMo · Card · Ladill Pay
|
Card · MoMo · Ladill Pay
|
||||||
</div>
|
</div>
|
||||||
<h1 class="mt-3 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">Your payment QRs</h1>
|
<h1 class="mt-3 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">Your payment QRs</h1>
|
||||||
<p class="mt-2 text-sm leading-6 text-slate-600">
|
<p class="mt-2 text-sm leading-6 text-slate-600">
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
payUrl: @js($payUrl),
|
payUrl: @js($payUrl),
|
||||||
csrf: @js($csrf),
|
csrf: @js($csrf),
|
||||||
amount: @js(old('amount')),
|
amount: @js(old('amount')),
|
||||||
phone: @js(old('customer_phone')),
|
|
||||||
errorMsg: @js(session('error')),
|
errorMsg: @js(session('error')),
|
||||||
})">
|
})">
|
||||||
|
|
||||||
@@ -64,12 +63,6 @@
|
|||||||
placeholder="0.00" value="{{ old('amount') }}">
|
placeholder="0.00" value="{{ old('amount') }}">
|
||||||
</div>
|
</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" name="customer_phone" x-model="phone" required
|
|
||||||
inputmode="tel" autocomplete="tel"
|
|
||||||
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" value="{{ old('customer_phone') }}">
|
|
||||||
|
|
||||||
<button type="submit" :disabled="loading"
|
<button type="submit" :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">
|
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" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24" style="display:none">
|
<svg x-show="loading" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24" style="display:none">
|
||||||
@@ -80,7 +73,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="mt-3 text-center text-[11px] text-slate-400">Pay with MTN MoMo. Powered by Ladill Pay</p>
|
<p class="mt-3 text-center text-[11px] text-slate-400">Secured by Paystack. Powered by Ladill Pay</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -113,12 +106,6 @@
|
|||||||
placeholder="0.00" value="{{ old('amount') }}">
|
placeholder="0.00" value="{{ old('amount') }}">
|
||||||
</div>
|
</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" name="customer_phone" x-model="phone" required
|
|
||||||
inputmode="tel" autocomplete="tel"
|
|
||||||
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" value="{{ old('customer_phone') }}">
|
|
||||||
|
|
||||||
<button type="submit" :disabled="loading"
|
<button type="submit" :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">
|
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" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24" style="display:none">
|
<svg x-show="loading" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24" style="display:none">
|
||||||
@@ -129,7 +116,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="mt-4 text-center text-[11px] text-slate-400">Pay with MTN MoMo. Powered by Ladill Pay</p>
|
<p class="mt-4 text-center text-[11px] text-slate-400">Secured by Paystack. Powered by Ladill Pay</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -146,7 +133,6 @@
|
|||||||
// Re-register so Pay works even when Blade deploys ahead of an old Vite bundle.
|
// Re-register so Pay works even when Blade deploys ahead of an old Vite bundle.
|
||||||
Alpine.data('miniPaymentLanding', (config = {}) => ({
|
Alpine.data('miniPaymentLanding', (config = {}) => ({
|
||||||
amount: config.amount ?? '',
|
amount: config.amount ?? '',
|
||||||
phone: config.phone ?? '',
|
|
||||||
loading: false,
|
loading: false,
|
||||||
errorMsg: config.errorMsg ?? '',
|
errorMsg: config.errorMsg ?? '',
|
||||||
showSheet: false,
|
showSheet: false,
|
||||||
@@ -178,11 +164,6 @@
|
|||||||
this.errorMsg = 'Enter an amount greater than zero.';
|
this.errorMsg = 'Enter an amount greater than zero.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const phone = String(this.phone || '').trim();
|
|
||||||
if (!phone) {
|
|
||||||
this.errorMsg = 'Enter your MoMo number to pay.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -196,7 +177,7 @@
|
|||||||
'X-CSRF-TOKEN': config.csrf,
|
'X-CSRF-TOKEN': config.csrf,
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ amount: value, customer_phone: phone }),
|
body: JSON.stringify({ amount: value }),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
@@ -211,8 +192,7 @@
|
|||||||
this.errorMsg = 'Could not start payment. Please try again.';
|
this.errorMsg = 'Could not start payment. Please try again.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// MoMo waiting page must be full-page — never trap it in the Paystack iframe
|
// Legacy MoMo waiting page must be full-page — never trap it in the Paystack iframe.
|
||||||
// (cross-origin iframe is blank / "does nothing").
|
|
||||||
const useSheet = data.provider !== 'mtn_momo' && window.innerWidth < 768;
|
const useSheet = data.provider !== 'mtn_momo' && window.innerWidth < 768;
|
||||||
if (useSheet) {
|
if (useSheet) {
|
||||||
this.checkoutUrl = data.checkout_url;
|
this.checkoutUrl = data.checkout_url;
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Pay\PayClient;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Mockery;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class MiniPaymentCheckoutTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_payment_landing_shows_paystack_branding_not_momo(): void
|
||||||
|
{
|
||||||
|
$user = User::create([
|
||||||
|
'public_id' => (string) Str::uuid(),
|
||||||
|
'name' => 'Vendor',
|
||||||
|
'email' => 'vendor@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$qr = QrCode::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'short_code' => 'payland01',
|
||||||
|
'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)
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Secured by Paystack. Powered by Ladill Pay', false)
|
||||||
|
->assertDontSee('MTN MoMo number', false)
|
||||||
|
->assertDontSee('Pay with MTN MoMo', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_pay_initiation_does_not_require_phone_and_returns_paystack_checkout(): void
|
||||||
|
{
|
||||||
|
$user = User::create([
|
||||||
|
'public_id' => (string) Str::uuid(),
|
||||||
|
'name' => 'Vendor',
|
||||||
|
'email' => 'vendor@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$qr = QrCode::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'short_code' => 'payinit01',
|
||||||
|
'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()
|
||||||
|
->with(Mockery::on(function (array $payload): bool {
|
||||||
|
return ($payload['source_service'] ?? '') === 'mini'
|
||||||
|
&& ! array_key_exists('customer_phone', $payload)
|
||||||
|
&& ($payload['line_items'][0]['unit_price_minor'] ?? 0) === 2500;
|
||||||
|
}))
|
||||||
|
->andReturn([
|
||||||
|
'id' => 99,
|
||||||
|
'reference' => 'LP-TESTMINIPAY001',
|
||||||
|
'checkout_url' => 'https://checkout.paystack.com/mini-test',
|
||||||
|
'provider' => 'paystack',
|
||||||
|
'platform_fee_minor' => 38,
|
||||||
|
'merchant_amount_minor' => 2462,
|
||||||
|
]);
|
||||||
|
$this->app->instance(PayClient::class, $pay);
|
||||||
|
|
||||||
|
$this->withHeader(\App\Support\LadillLink::INTERNAL_HEADER, '1')
|
||||||
|
->postJson('/q/'.$qr->short_code.'/pay', [
|
||||||
|
'amount' => 25,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('checkout_url', 'https://checkout.paystack.com/mini-test')
|
||||||
|
->assertJsonPath('provider', 'paystack');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('mini_payments', [
|
||||||
|
'qr_code_id' => $qr->id,
|
||||||
|
'amount_minor' => 2500,
|
||||||
|
'payment_reference' => 'LP-TESTMINIPAY001',
|
||||||
|
'payer_phone' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user