diff --git a/app/Models/PaymentGatewaySetting.php b/app/Models/PaymentGatewaySetting.php index 7c34791..7d378a6 100644 --- a/app/Models/PaymentGatewaySetting.php +++ b/app/Models/PaymentGatewaySetting.php @@ -39,12 +39,14 @@ class PaymentGatewaySetting extends Model return false; } + $public = trim((string) $this->public_key); $secret = trim((string) $this->secret_key); - return $secret !== '' && in_array($this->provider, [ - self::PROVIDER_PAYSTACK, - self::PROVIDER_FLUTTERWAVE, - self::PROVIDER_HUBTEL, - ], true); + return match ($this->provider) { + self::PROVIDER_PAYSTACK => str_starts_with($public, 'pk_') && str_starts_with($secret, 'sk_'), + self::PROVIDER_FLUTTERWAVE => $public !== '' && $secret !== '', + self::PROVIDER_HUBTEL => $public !== '' && $secret !== '', + default => false, + }; } } diff --git a/app/Services/Payments/MerchantGatewayService.php b/app/Services/Payments/MerchantGatewayService.php index dd7569b..87717f2 100644 --- a/app/Services/Payments/MerchantGatewayService.php +++ b/app/Services/Payments/MerchantGatewayService.php @@ -4,12 +4,15 @@ namespace App\Services\Payments; use App\Models\PaymentGatewaySetting; use App\Models\User; +use App\Services\Pay\PayClient; use App\Services\Pos\SubscriptionService; use Illuminate\Support\Facades\Http; use RuntimeException; class MerchantGatewayService { + public function __construct(private readonly PayClient $pay) {} + public function settingFor(User|string $owner): ?PaymentGatewaySetting { $ownerRef = $owner instanceof User ? (string) $owner->public_id : $owner; @@ -19,11 +22,13 @@ class MerchantGatewayService public function isConfigured(User|string $owner): bool { - if (! $this->ownerMayUseGateway($owner)) { - return false; - } + return $this->shouldUseOwnerGateway($owner); + } - return (bool) $this->settingFor($owner)?->isConfigured(); + public function shouldUseOwnerGateway(User|string $owner): bool + { + return $this->ownerMayUseGateway($owner) + && (bool) $this->settingFor($owner)?->isConfigured(); } /** @@ -39,15 +44,25 @@ class MerchantGatewayService string $reference, array $metadata = [], ): array { - $setting = $this->requireConfigured($owner); $currency = strtoupper($currency ?: 'GHS'); + $email = (string) config('pay.mini_checkout_email', 'pay@ladill.com'); - return match ($setting->provider) { - PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata), - PaymentGatewaySetting::PROVIDER_FLUTTERWAVE => $this->flutterwaveInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata), - PaymentGatewaySetting::PROVIDER_HUBTEL => $this->hubtelInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata), - default => throw new RuntimeException('Unsupported payment provider.'), - }; + if (! $this->shouldUseOwnerGateway($owner)) { + return $this->ladillPayInitialize($owner, $amountMinor, $email, $callbackUrl, $reference, $metadata); + } + + $setting = $this->requireConfigured($owner); + + try { + return match ($setting->provider) { + PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata), + PaymentGatewaySetting::PROVIDER_FLUTTERWAVE => $this->flutterwaveInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata), + PaymentGatewaySetting::PROVIDER_HUBTEL => $this->hubtelInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata), + default => throw new RuntimeException('Unsupported payment provider.'), + }; + } catch (\Throwable) { + return $this->ladillPayInitialize($owner, $amountMinor, $email, $callbackUrl, $reference, $metadata); + } } /** @@ -55,7 +70,24 @@ class MerchantGatewayService */ public function verify(User|string $owner, string $reference): array { - $setting = $this->requireConfigured($owner); + if (str_starts_with($reference, 'LP-')) { + $result = $this->pay->verify($reference); + + return [ + 'paid' => in_array(strtolower((string) ($result['status'] ?? '')), ['paid', 'success'], true), + 'amount_minor' => (int) ($result['amount_minor'] ?? 0), + 'reference' => (string) ($result['reference'] ?? $reference), + 'provider' => 'ladill_pay', + 'raw' => $result, + ]; + } + + // Verification intentionally ignores current plan state so payments + // initiated before a downgrade can still complete safely. + $setting = $this->settingFor($owner); + if (! $setting?->isConfigured()) { + throw new RuntimeException('The payment gateway used for this transaction is no longer configured.'); + } return match ($setting->provider) { PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackVerify($setting, $reference), @@ -79,6 +111,32 @@ class MerchantGatewayService return $setting; } + /** @return array{checkout_url:string,reference:string,provider:string} */ + private function ladillPayInitialize(User|string $owner, int $amountMinor, string $email, string $callbackUrl, string $reference, array $metadata): array + { + $ownerRef = $owner instanceof User ? (string) $owner->public_id : (string) $owner; + $checkout = $this->pay->createCheckout([ + 'merchant' => $ownerRef, + 'fee_tier' => 'sales', + 'source_service' => 'pos', + 'source_ref' => (string) ($metadata['pos_sale_id'] ?? $metadata['pos_payment_id'] ?? $reference), + 'callback_url' => $callbackUrl, + 'customer_email' => $email, + 'line_items' => [[ + 'name' => (string) ($metadata['title'] ?? 'POS payment'), + 'unit_price_minor' => $amountMinor, + 'quantity' => 1, + ]], + 'metadata' => $metadata, + ]); + + return [ + 'checkout_url' => (string) $checkout['checkout_url'], + 'reference' => (string) $checkout['reference'], + 'provider' => 'ladill_pay', + ]; + } + protected function ownerMayUseGateway(User|string $owner): bool { $user = $owner instanceof User diff --git a/resources/views/pos/settings.blade.php b/resources/views/pos/settings.blade.php index e079820..649b7a3 100644 --- a/resources/views/pos/settings.blade.php +++ b/resources/views/pos/settings.blade.php @@ -123,16 +123,21 @@

Payment gateway

-

Connect Paystack, Flutterwave, or Hubtel. Online checkouts go 100% to you — 0% Ladill fee. Pro & Business plans only.

+

Ladill Pay is recommended, selected by default, and needs no API keys. Pro and Business can optionally use an owner gateway.

+
+
+

Ladill Pay — recommended default

+

Zero-config card and MoMo checkout. It remains the safe fallback whenever an owner gateway is unavailable.

@if ($canUsePaymentGateway ?? false)
@@ -151,21 +156,21 @@
@if ($gateway?->isConfigured())

Gateway connected ({{ ucfirst($gateway->provider) }}).

@else -

Online checkouts stay disabled until a gateway is connected.

+

Ladill Pay remains active. Add valid credentials only if you want direct owner-gateway settlement.

@endif @else

Your own payment gateway is a Pro feature

-

Upgrade to Pro or Business to connect Paystack, Flutterwave, or Hubtel and take card / MoMo payments with 0% Ladill fee. Cash sales stay free.

+

Free uses Ladill Pay automatically. Upgrade to optionally connect Paystack, Flutterwave, or Hubtel.

View plans →
@endif diff --git a/tests/Feature/PosProTest.php b/tests/Feature/PosProTest.php index 7d83509..3f61d66 100644 --- a/tests/Feature/PosProTest.php +++ b/tests/Feature/PosProTest.php @@ -25,6 +25,8 @@ class PosProTest extends TestCase 'pos.pro.price_minor' => 7900, 'pos.plans.pro.price_minor' => 7900, 'pos.plans.enterprise.price_minor' => 24900, + 'pay.api_url' => 'https://ladill.com/api/pay', + 'pay.api_key' => 'pos-pay-key', ]); } @@ -107,6 +109,48 @@ class PosProTest extends TestCase ); } + public function test_free_and_invalid_owner_gateway_fall_back_to_ladill_pay(): void + { + Http::fake(['*/api/pay/checkouts' => Http::response([ + 'reference' => 'LP-POS-FALLBACK', + 'checkout_url' => 'https://checkout.paystack.com/fallback', + ], 201)]); + $user = $this->user(); + \App\Models\PaymentGatewaySetting::create([ + 'owner_ref' => $user->public_id, + 'provider' => 'paystack', + 'public_key' => 'invalid', + 'secret_key' => 'invalid', + 'is_active' => true, + ]); + + $result = app(\App\Services\Payments\MerchantGatewayService::class)->initializeCheckout( + $user, 1000, 'GHS', 'seller@example.com', 'https://pos.test/callback', 'POSP-OLD', + ); + + $this->assertSame('ladill_pay', $result['provider']); + $this->assertSame('LP-POS-FALLBACK', $result['reference']); + } + + public function test_downgrade_disables_owner_gateway_without_deleting_credentials(): void + { + $user = $this->user(); + ProSubscription::create([ + 'user_id' => $user->id, 'plan' => 'pro', 'status' => 'active', + 'price_minor' => 7900, 'current_period_end' => now()->addMonth(), + ]); + $setting = \App\Models\PaymentGatewaySetting::create([ + 'owner_ref' => $user->public_id, 'provider' => 'paystack', + 'public_key' => 'pk_test_saved', 'secret_key' => 'sk_test_saved', 'is_active' => true, + ]); + $this->assertTrue(app(\App\Services\Payments\MerchantGatewayService::class)->shouldUseOwnerGateway($user)); + + ProSubscription::where('user_id', $user->id)->update(['status' => 'past_due']); + + $this->assertFalse(app(\App\Services\Payments\MerchantGatewayService::class)->shouldUseOwnerGateway($user->fresh())); + $this->assertDatabaseHas('payment_gateway_settings', ['id' => $setting->id, 'is_active' => true]); + } + public function test_subscribe_enterprise_charges_wallet_and_activates(): void { Http::fake(['*/debit' => Http::response(['id' => 1], 201)]);