From 5166cd8a64e8d088f48d536549e5dea03ce0f843 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Tue, 21 Jul 2026 19:47:13 +0000 Subject: [PATCH] Add optional owner gateway routing to Merchant. Co-authored-by: Cursor --- app/Http/Controllers/Qr/AccountController.php | 35 +++++- app/Models/PaymentGatewaySetting.php | 30 +++++ app/Services/Billing/BillingClient.php | 9 ++ .../Billing/PlanEntitlementService.php | 27 +++++ app/Services/Merchant/MerchantSaleService.php | 108 ++++++++++++++++-- .../Payments/MerchantGatewayService.php | 77 +++++++++++++ ..._create_payment_gateway_settings_table.php | 27 +++++ resources/views/merchant/settings.blade.php | 26 +++++ tests/Feature/PaymentGatewayPolicyTest.php | 51 +++++++++ 9 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 app/Models/PaymentGatewaySetting.php create mode 100644 app/Services/Billing/PlanEntitlementService.php create mode 100644 app/Services/Payments/MerchantGatewayService.php create mode 100644 database/migrations/2026_07_21_193000_create_payment_gateway_settings_table.php create mode 100644 tests/Feature/PaymentGatewayPolicyTest.php diff --git a/app/Http/Controllers/Qr/AccountController.php b/app/Http/Controllers/Qr/AccountController.php index cc90cf4..419a058 100644 --- a/app/Http/Controllers/Qr/AccountController.php +++ b/app/Http/Controllers/Qr/AccountController.php @@ -3,15 +3,23 @@ namespace App\Http\Controllers\Qr; use App\Http\Controllers\Controller; +use App\Models\PaymentGatewaySetting; use App\Models\QrSetting; use App\Services\Billing\BillingClient; +use App\Services\Billing\PlanEntitlementService; +use App\Services\Payments\MerchantGatewayService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; use Illuminate\View\View; class AccountController extends Controller { - public function __construct(private BillingClient $billing) {} + public function __construct( + private BillingClient $billing, + private PlanEntitlementService $entitlements, + private MerchantGatewayService $gateway, + ) {} private function topupUrl(): string { @@ -65,6 +73,8 @@ class AccountController extends Controller return view('merchant.settings', [ 'account' => $account, 'settings' => $settings, + 'gateway' => $this->gateway->settingFor($account), + 'canUsePaymentGateway' => $this->entitlements->canUseCustomPaymentGateway($account), ]); } @@ -77,6 +87,10 @@ class AccountController extends Controller 'product_updates' => ['nullable', 'boolean'], 'notify_registrations' => ['nullable', 'boolean'], 'notify_payouts' => ['nullable', 'boolean'], + 'gateway_provider' => ['nullable', Rule::in(['', PaymentGatewaySetting::PROVIDER_PAYSTACK])], + 'gateway_public_key' => ['nullable', 'string', 'max:2000'], + 'gateway_secret_key' => ['nullable', 'string', 'max:2000'], + 'gateway_is_active' => ['nullable', 'boolean'], ]); QrSetting::updateOrCreate( @@ -89,6 +103,25 @@ class AccountController extends Controller ], ); + if ($this->entitlements->canUseCustomPaymentGateway($account)) { + $provider = trim((string) ($data['gateway_provider'] ?? '')); + $setting = PaymentGatewaySetting::query()->where('owner_ref', $account->public_id)->first(); + if ($provider !== '' || $setting) { + $setting ??= new PaymentGatewaySetting(['owner_ref' => $account->public_id]); + if ($provider !== '') { + $setting->provider = $provider; + } + if (filled($data['gateway_public_key'] ?? null)) { + $setting->public_key = $data['gateway_public_key']; + } + if (filled($data['gateway_secret_key'] ?? null)) { + $setting->secret_key = $data['gateway_secret_key']; + } + $setting->is_active = $provider !== '' && $request->boolean('gateway_is_active'); + $setting->save(); + } + } + return redirect()->route('account.settings')->with('success', 'Settings saved.'); } } diff --git a/app/Models/PaymentGatewaySetting.php b/app/Models/PaymentGatewaySetting.php new file mode 100644 index 0000000..75f8a14 --- /dev/null +++ b/app/Models/PaymentGatewaySetting.php @@ -0,0 +1,30 @@ + 'encrypted', + 'secret_key' => 'encrypted', + 'is_active' => 'boolean', + 'metadata' => 'array', + ]; + } + + public function isConfigured(): bool + { + return $this->is_active + && $this->provider === self::PROVIDER_PAYSTACK + && str_starts_with(trim((string) $this->public_key), 'pk_') + && str_starts_with(trim((string) $this->secret_key), 'sk_'); + } +} diff --git a/app/Services/Billing/BillingClient.php b/app/Services/Billing/BillingClient.php index 4c7caef..23aa76f 100644 --- a/app/Services/Billing/BillingClient.php +++ b/app/Services/Billing/BillingClient.php @@ -45,6 +45,15 @@ class BillingClient return $this->get('/service-ledger', ['user' => $publicId, 'service' => $service]); } + /** @return array */ + public function suiteEntitlement(string $publicId): array + { + return (array) ($this->get('/suite-entitlements', [ + 'user' => $publicId, + 'app' => (string) config('billing.service', 'merchant'), + ])['data'] ?? []); + } + /** * Debit the wallet. Returns true on success, false on insufficient balance * (HTTP 402). Idempotent by $reference. diff --git a/app/Services/Billing/PlanEntitlementService.php b/app/Services/Billing/PlanEntitlementService.php new file mode 100644 index 0000000..d318d40 --- /dev/null +++ b/app/Services/Billing/PlanEntitlementService.php @@ -0,0 +1,27 @@ +billing->suiteEntitlement((string) $owner->public_id)['features'] ?? []), true); + } catch (Throwable) { + return false; + } + } + + public function canUseCustomPaymentGateway(User $owner): bool + { + return $this->has($owner, self::CUSTOM_PAYMENT_GATEWAY); + } +} diff --git a/app/Services/Merchant/MerchantSaleService.php b/app/Services/Merchant/MerchantSaleService.php index 28a3a2c..de87705 100644 --- a/app/Services/Merchant/MerchantSaleService.php +++ b/app/Services/Merchant/MerchantSaleService.php @@ -10,6 +10,7 @@ use App\Services\Billing\BillingClient; use App\Services\Billing\PaystackService; use App\Services\Billing\SmsService; use App\Services\Pay\PayClient; +use App\Services\Payments\MerchantGatewayService; use App\Support\LadillLink; use App\Support\Qr\QrTypeCatalog; use RuntimeException; @@ -22,6 +23,7 @@ class MerchantSaleService private BillingClient $billing, private SmsService $sms, private KitchenPusher $kitchen, + private MerchantGatewayService $gateway, ) {} /** @@ -53,7 +55,23 @@ class MerchantSaleService $customerEmail = trim((string) ($data['customer_email'] ?? '')); $callbackUrl = LadillLink::path($qrCode->short_code, 'order/callback'); - $payOrder = $this->pay->createCheckout([ + $usesOwnerGateway = $this->gateway->shouldUseOwnerGateway($qrCode->user); + $payOrder = null; + if ($usesOwnerGateway) { + try { + $payOrder = $this->gateway->initialize( + $qrCode->user, (int) round($totalGhs * 100), + (string) ($qrCode->content()['currency'] ?? 'GHS'), + (string) config('pay.mini_checkout_email', 'pay@ladill.com'), $callbackUrl, + 'MGP-'.strtoupper(\Illuminate\Support\Str::random(16)), + ['qr_code_id' => $qrCode->id, 'short_code' => $qrCode->short_code, 'type' => 'order'], + ); + } catch (\Throwable) { + $usesOwnerGateway = false; + } + } + if (! $usesOwnerGateway) { + $payOrder = $this->pay->createCheckout([ 'merchant' => $qrCode->user->public_id, 'fee_tier' => 'sales', 'source_service' => 'merchant', @@ -71,17 +89,18 @@ class MerchantSaleService 'qr_code_id' => $qrCode->id, 'short_code' => $qrCode->short_code, ], - ]); + ]); + } $order = QrSaleOrder::create([ - 'pay_order_id' => $payOrder['id'] ?? null, + 'pay_order_id' => $usesOwnerGateway ? null : ($payOrder['id'] ?? null), 'qr_code_id' => $qrCode->id, 'user_id' => $qrCode->user_id, 'customer_name' => trim((string) ($data['customer_name'] ?? '')), 'customer_email' => $customerEmail ?: null, 'customer_phone' => trim((string) ($data['customer_phone'] ?? '')) ?: null, 'items' => $items, - 'amount_ghs' => round(($payOrder['amount_minor'] ?? 0) / 100, 2), + 'amount_ghs' => $usesOwnerGateway ? $totalGhs : round(($payOrder['amount_minor'] ?? 0) / 100, 2), 'status' => QrSaleOrder::STATUS_PENDING, 'payment_reference' => $payOrder['reference'], ]); @@ -154,7 +173,23 @@ class MerchantSaleService } $lineLabel = sprintf('%s — %s', $serviceName, $startsAt->format('D j M, g:i A')); - $payOrder = $this->pay->createCheckout([ + $usesOwnerGateway = $this->gateway->shouldUseOwnerGateway($qrCode->user); + $payOrder = null; + if ($usesOwnerGateway) { + try { + $payOrder = $this->gateway->initialize( + $qrCode->user, (int) round($priceGhs * 100), + (string) ($content['currency'] ?? 'GHS'), + (string) config('pay.mini_checkout_email', 'pay@ladill.com'), $callbackUrl, + 'MGP-'.strtoupper(\Illuminate\Support\Str::random(16)), + ['qr_code_id' => $qrCode->id, 'qr_booking_id' => $booking->id, 'type' => 'booking'], + ); + } catch (\Throwable) { + $usesOwnerGateway = false; + } + } + if (! $usesOwnerGateway) { + $payOrder = $this->pay->createCheckout([ 'merchant' => $qrCode->user->public_id, 'fee_tier' => 'sales', 'source_service' => 'merchant', @@ -171,10 +206,11 @@ class MerchantSaleService 'qr_booking_id' => $booking->id, 'type' => 'booking', ], - ]); + ]); + } $booking->update([ - 'pay_order_id' => $payOrder['id'] ?? null, + 'pay_order_id' => $usesOwnerGateway ? null : ($payOrder['id'] ?? null), 'payment_reference' => $payOrder['reference'], ]); @@ -192,6 +228,9 @@ class MerchantSaleService if (str_starts_with($reference, 'LP-')) { return $this->completeLadillPayBooking($reference); } + if (str_starts_with($reference, 'MGP-')) { + return $this->completeOwnerGatewayBooking($reference); + } return $this->completeLegacyBooking($reference); } @@ -201,6 +240,9 @@ class MerchantSaleService if (str_starts_with($reference, 'LP-')) { return $this->completeLadillPay($reference); } + if (str_starts_with($reference, 'MGP-')) { + return $this->completeOwnerGateway($reference); + } return $this->completeLegacy($reference); } @@ -269,6 +311,32 @@ class MerchantSaleService return $order; } + private function completeOwnerGateway(string $reference): QrSaleOrder + { + $order = QrSaleOrder::where('payment_reference', $reference) + ->where('status', QrSaleOrder::STATUS_PENDING) + ->with('merchant') + ->firstOrFail(); + $result = $this->gateway->verify($order->merchant, $reference); + if (! $result['paid']) { + $order->update(['status' => QrSaleOrder::STATUS_FAILED]); + throw new RuntimeException('Payment was not successful.'); + } + $paidMinor = (int) ($result['amount_minor'] ?: round($order->amount_ghs * 100)); + $order->update([ + 'status' => QrSaleOrder::STATUS_PAID, + 'paid_at' => now(), + 'amount_ghs' => round($paidMinor / 100, 2), + 'platform_fee_ghs' => 0, + 'merchant_amount_ghs' => round($paidMinor / 100, 2), + 'metadata' => array_merge((array) $order->metadata, ['merchant_gateway' => $result]), + ]); + $this->notifyOrderPlaced($order->fresh(['qrCode', 'merchant'])); + $this->kitchen->push($order->fresh(['qrCode', 'merchant'])); + + return $order; + } + private function completeLadillPayBooking(string $reference): QrBooking { $booking = QrBooking::where('payment_reference', $reference) @@ -332,6 +400,32 @@ class MerchantSaleService return $booking; } + private function completeOwnerGatewayBooking(string $reference): QrBooking + { + $booking = QrBooking::where('payment_reference', $reference) + ->where('status', QrBooking::STATUS_PENDING) + ->with('merchant') + ->firstOrFail(); + $result = $this->gateway->verify($booking->merchant, $reference); + if (! $result['paid']) { + $booking->update(['status' => QrBooking::STATUS_FAILED]); + throw new RuntimeException('Payment was not successful.'); + } + $paidMinor = (int) ($result['amount_minor'] ?: round($booking->amount_ghs * 100)); + $booking->update([ + 'status' => QrBooking::STATUS_CONFIRMED, + 'fulfillment_status' => QrBooking::FULFILLMENT_CONFIRMED, + 'paid_at' => now(), + 'amount_ghs' => round($paidMinor / 100, 2), + 'platform_fee_ghs' => 0, + 'merchant_amount_ghs' => round($paidMinor / 100, 2), + 'metadata' => array_merge((array) $booking->metadata, ['merchant_gateway' => $result]), + ]); + $this->notifyBookingConfirmed($booking->fresh(['qrCode'])); + + return $booking; + } + private function notifyBookingConfirmed(QrBooking $booking): void { if (! $booking->customer_phone) { diff --git a/app/Services/Payments/MerchantGatewayService.php b/app/Services/Payments/MerchantGatewayService.php new file mode 100644 index 0000000..e795ec8 --- /dev/null +++ b/app/Services/Payments/MerchantGatewayService.php @@ -0,0 +1,77 @@ +public_id : (string) $owner; + + return PaymentGatewaySetting::query()->where('owner_ref', $ownerRef)->first(); + } + + public function shouldUseOwnerGateway(User $owner): bool + { + return $this->entitlements->canUseCustomPaymentGateway($owner) + && (bool) $this->settingFor($owner)?->isConfigured(); + } + + /** @return array{checkout_url:string,reference:string,provider:string} */ + public function initialize(User $owner, int $amountMinor, string $currency, string $email, string $callbackUrl, string $reference, array $metadata = []): array + { + if (! $this->shouldUseOwnerGateway($owner)) { + throw new RuntimeException('Owner gateway is not available.'); + } + + $setting = $this->settingFor($owner); + $response = Http::withToken((string) $setting->secret_key)->acceptJson()->timeout(20) + ->post('https://api.paystack.co/transaction/initialize', [ + 'email' => $email !== '' ? $email : 'pay@ladill.com', + 'amount' => $amountMinor, + 'currency' => strtoupper($currency ?: 'GHS'), + 'reference' => $reference, + 'callback_url' => $callbackUrl, + 'metadata' => $metadata, + ]); + + if (! $response->successful() || ! ($response->json('status') ?? false) || ! $response->json('data.authorization_url')) { + throw new RuntimeException($response->json('message') ?: 'Paystack could not start checkout.'); + } + + return [ + 'checkout_url' => (string) $response->json('data.authorization_url'), + 'reference' => (string) ($response->json('data.reference') ?: $reference), + 'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK, + ]; + } + + /** @return array */ + public function verify(User|string $owner, string $reference): array + { + $setting = $this->settingFor($owner); + if (! $setting?->isConfigured()) { + throw new RuntimeException('The owner gateway used for this payment is no longer configured.'); + } + + $response = Http::withToken((string) $setting->secret_key)->acceptJson()->timeout(20) + ->get('https://api.paystack.co/transaction/verify/'.rawurlencode($reference)); + $data = (array) ($response->json('data') ?? []); + + return [ + 'paid' => ($response->json('status') ?? false) && strtolower((string) ($data['status'] ?? '')) === 'success', + 'amount_minor' => (int) ($data['amount'] ?? 0), + 'reference' => (string) ($data['reference'] ?? $reference), + 'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK, + 'raw' => $data, + ]; + } +} diff --git a/database/migrations/2026_07_21_193000_create_payment_gateway_settings_table.php b/database/migrations/2026_07_21_193000_create_payment_gateway_settings_table.php new file mode 100644 index 0000000..c9cb7c2 --- /dev/null +++ b/database/migrations/2026_07_21_193000_create_payment_gateway_settings_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('owner_ref', 64)->unique(); + $table->string('provider', 32); + $table->text('public_key')->nullable(); + $table->text('secret_key')->nullable(); + $table->boolean('is_active')->default(false); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('payment_gateway_settings'); + } +}; diff --git a/resources/views/merchant/settings.blade.php b/resources/views/merchant/settings.blade.php index 8b12431..3d199ab 100644 --- a/resources/views/merchant/settings.blade.php +++ b/resources/views/merchant/settings.blade.php @@ -56,6 +56,32 @@ +
+
+

Customer payment engine

+

Ladill Pay is recommended, zero-config, and selected by default.

+
+

Ladill Pay remains the safe fallback and requires no owner keys.

+ @if ($canUsePaymentGateway ?? false) + +
+ + +
+ +

Optional. Invalid or incomplete keys automatically fall back to Ladill Pay.

+ @else +

Free uses Ladill Pay only. Pro and higher plans may optionally connect owner Paystack.

+ @endif +
+ diff --git a/tests/Feature/PaymentGatewayPolicyTest.php b/tests/Feature/PaymentGatewayPolicyTest.php new file mode 100644 index 0000000..abb93d6 --- /dev/null +++ b/tests/Feature/PaymentGatewayPolicyTest.php @@ -0,0 +1,51 @@ + 'https://ladill.com/api/billing', 'billing.api_key' => 'merchant-test']); + $owner = User::factory()->create(['public_id' => 'usr_merchant_policy']); + $setting = PaymentGatewaySetting::create([ + 'owner_ref' => $owner->public_id, 'provider' => 'paystack', + 'public_key' => 'pk_test_saved', 'secret_key' => 'sk_test_saved', 'is_active' => true, + ]); + + Http::fakeSequence() + ->push(['data' => ['effective_plan' => 'free', 'features' => []]]) + ->push(['data' => ['effective_plan' => 'pro', 'features' => ['custom_payment_gateway']]]) + ->push(['data' => ['effective_plan' => 'pro', 'features' => ['custom_payment_gateway']]]); + $this->assertFalse(app(MerchantGatewayService::class)->shouldUseOwnerGateway($owner)); + + $setting->update(['is_active' => false]); + $this->assertFalse(app(MerchantGatewayService::class)->shouldUseOwnerGateway($owner)); + $setting->update(['is_active' => true]); + $this->assertTrue(app(MerchantGatewayService::class)->shouldUseOwnerGateway($owner)); + } + + public function test_invalid_pro_credentials_fall_back_to_ladill_pay(): void + { + config(['billing.api_url' => 'https://ladill.com/api/billing', 'billing.api_key' => 'merchant-test']); + Http::fake(['*/suite-entitlements*' => Http::response(['data' => [ + 'effective_plan' => 'business', 'features' => ['custom_payment_gateway'], + ]])]); + $owner = User::factory()->create(['public_id' => 'usr_merchant_invalid']); + PaymentGatewaySetting::create([ + 'owner_ref' => $owner->public_id, 'provider' => 'paystack', + 'public_key' => 'invalid', 'secret_key' => 'invalid', 'is_active' => true, + ]); + + $this->assertFalse(app(MerchantGatewayService::class)->shouldUseOwnerGateway($owner)); + } +}