Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -39,12 +39,14 @@ class PaymentGatewaySetting extends Model
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$public = trim((string) $this->public_key);
|
||||||
$secret = trim((string) $this->secret_key);
|
$secret = trim((string) $this->secret_key);
|
||||||
|
|
||||||
return $secret !== '' && in_array($this->provider, [
|
return match ($this->provider) {
|
||||||
self::PROVIDER_PAYSTACK,
|
self::PROVIDER_PAYSTACK => str_starts_with($public, 'pk_') && str_starts_with($secret, 'sk_'),
|
||||||
self::PROVIDER_FLUTTERWAVE,
|
self::PROVIDER_FLUTTERWAVE => $public !== '' && $secret !== '',
|
||||||
self::PROVIDER_HUBTEL,
|
self::PROVIDER_HUBTEL => $public !== '' && $secret !== '',
|
||||||
], true);
|
default => false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ namespace App\Services\Payments;
|
|||||||
|
|
||||||
use App\Models\PaymentGatewaySetting;
|
use App\Models\PaymentGatewaySetting;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Pay\PayClient;
|
||||||
use App\Services\Pos\SubscriptionService;
|
use App\Services\Pos\SubscriptionService;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
class MerchantGatewayService
|
class MerchantGatewayService
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly PayClient $pay) {}
|
||||||
|
|
||||||
public function settingFor(User|string $owner): ?PaymentGatewaySetting
|
public function settingFor(User|string $owner): ?PaymentGatewaySetting
|
||||||
{
|
{
|
||||||
$ownerRef = $owner instanceof User ? (string) $owner->public_id : $owner;
|
$ownerRef = $owner instanceof User ? (string) $owner->public_id : $owner;
|
||||||
@@ -19,11 +22,13 @@ class MerchantGatewayService
|
|||||||
|
|
||||||
public function isConfigured(User|string $owner): bool
|
public function isConfigured(User|string $owner): bool
|
||||||
{
|
{
|
||||||
if (! $this->ownerMayUseGateway($owner)) {
|
return $this->shouldUseOwnerGateway($owner);
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
string $reference,
|
||||||
array $metadata = [],
|
array $metadata = [],
|
||||||
): array {
|
): array {
|
||||||
$setting = $this->requireConfigured($owner);
|
|
||||||
$currency = strtoupper($currency ?: 'GHS');
|
$currency = strtoupper($currency ?: 'GHS');
|
||||||
|
$email = (string) config('pay.mini_checkout_email', 'pay@ladill.com');
|
||||||
|
|
||||||
return match ($setting->provider) {
|
if (! $this->shouldUseOwnerGateway($owner)) {
|
||||||
PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
|
return $this->ladillPayInitialize($owner, $amountMinor, $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.'),
|
$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
|
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) {
|
return match ($setting->provider) {
|
||||||
PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackVerify($setting, $reference),
|
PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackVerify($setting, $reference),
|
||||||
@@ -79,6 +111,32 @@ class MerchantGatewayService
|
|||||||
return $setting;
|
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
|
protected function ownerMayUseGateway(User|string $owner): bool
|
||||||
{
|
{
|
||||||
$user = $owner instanceof User
|
$user = $owner instanceof User
|
||||||
|
|||||||
@@ -123,16 +123,21 @@
|
|||||||
|
|
||||||
<div class="border-t border-slate-100 pt-4">
|
<div class="border-t border-slate-100 pt-4">
|
||||||
<p class="text-sm font-semibold text-slate-900">Payment gateway</p>
|
<p class="text-sm font-semibold text-slate-900">Payment gateway</p>
|
||||||
<p class="mt-1 text-xs text-slate-400">Connect Paystack, Flutterwave, or Hubtel. Online checkouts go 100% to you — 0% Ladill fee. Pro & Business plans only.</p>
|
<p class="mt-1 text-xs text-slate-400">Ladill Pay is recommended, selected by default, and needs no API keys. Pro and Business can optionally use an owner gateway.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
|
||||||
|
<p class="font-medium">Ladill Pay — recommended default</p>
|
||||||
|
<p class="mt-1 text-emerald-800/80">Zero-config card and MoMo checkout. It remains the safe fallback whenever an owner gateway is unavailable.</p>
|
||||||
</div>
|
</div>
|
||||||
@if ($canUsePaymentGateway ?? false)
|
@if ($canUsePaymentGateway ?? false)
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium text-slate-700">Provider</label>
|
<label class="text-sm font-medium text-slate-700">Provider</label>
|
||||||
<select name="gateway_provider" class="mt-1.5 block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
<select name="gateway_provider" class="mt-1.5 block w-full rounded-xl border-slate-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||||
<option value="">Select a provider</option>
|
@php $selectedGateway = old('gateway_provider', $gateway?->is_active ? $gateway?->provider : ''); @endphp
|
||||||
<option value="paystack" @selected(old('gateway_provider', $gateway?->provider ?? null) === 'paystack')>Paystack</option>
|
<option value="">Ladill Pay (recommended)</option>
|
||||||
<option value="flutterwave" @selected(old('gateway_provider', $gateway?->provider ?? null) === 'flutterwave')>Flutterwave</option>
|
<option value="paystack" @selected($selectedGateway === 'paystack')>Paystack</option>
|
||||||
<option value="hubtel" @selected(old('gateway_provider', $gateway?->provider ?? null) === 'hubtel')>Hubtel</option>
|
<option value="flutterwave" @selected($selectedGateway === 'flutterwave')>Flutterwave</option>
|
||||||
|
<option value="hubtel" @selected($selectedGateway === 'hubtel')>Hubtel</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
@@ -151,21 +156,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="flex items-start gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
<label class="flex items-start gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||||
<input type="hidden" name="gateway_is_active" value="0">
|
<input type="hidden" name="gateway_is_active" value="0">
|
||||||
<input type="checkbox" name="gateway_is_active" value="1" @checked(old('gateway_is_active', $gateway?->is_active ?? true)) class="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
<input type="checkbox" name="gateway_is_active" value="1" @checked(old('gateway_is_active', $gateway?->is_active ?? false)) class="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||||
<span>
|
<span>
|
||||||
<span class="block text-sm font-medium text-slate-800">Gateway enabled</span>
|
<span class="block text-sm font-medium text-slate-800">Use my gateway instead of Ladill Pay</span>
|
||||||
<span class="mt-0.5 block text-xs text-slate-500">Required for card / MoMo checkouts on the register.</span>
|
<span class="mt-0.5 block text-xs text-slate-500">Optional. Invalid or incomplete credentials automatically fall back to Ladill Pay.</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@if ($gateway?->isConfigured())
|
@if ($gateway?->isConfigured())
|
||||||
<p class="rounded-xl bg-emerald-50 px-3 py-2 text-sm text-emerald-800">Gateway connected ({{ ucfirst($gateway->provider) }}).</p>
|
<p class="rounded-xl bg-emerald-50 px-3 py-2 text-sm text-emerald-800">Gateway connected ({{ ucfirst($gateway->provider) }}).</p>
|
||||||
@else
|
@else
|
||||||
<p class="rounded-xl bg-amber-50 px-3 py-2 text-sm text-amber-800">Online checkouts stay disabled until a gateway is connected.</p>
|
<p class="rounded-xl bg-slate-50 px-3 py-2 text-sm text-slate-700">Ladill Pay remains active. Add valid credentials only if you want direct owner-gateway settlement.</p>
|
||||||
@endif
|
@endif
|
||||||
@else
|
@else
|
||||||
<div class="rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm text-indigo-900">
|
<div class="rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm text-indigo-900">
|
||||||
<p class="font-medium">Your own payment gateway is a Pro feature</p>
|
<p class="font-medium">Your own payment gateway is a Pro feature</p>
|
||||||
<p class="mt-1 text-indigo-800/80">Upgrade to Pro or Business to connect Paystack, Flutterwave, or Hubtel and take card / MoMo payments with 0% Ladill fee. Cash sales stay free.</p>
|
<p class="mt-1 text-indigo-800/80">Free uses Ladill Pay automatically. Upgrade to optionally connect Paystack, Flutterwave, or Hubtel.</p>
|
||||||
<a href="{{ route('pos.pro.index') }}" class="mt-3 inline-flex text-sm font-semibold text-indigo-700 hover:text-indigo-900">View plans →</a>
|
<a href="{{ route('pos.pro.index') }}" class="mt-3 inline-flex text-sm font-semibold text-indigo-700 hover:text-indigo-900">View plans →</a>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ class PosProTest extends TestCase
|
|||||||
'pos.pro.price_minor' => 7900,
|
'pos.pro.price_minor' => 7900,
|
||||||
'pos.plans.pro.price_minor' => 7900,
|
'pos.plans.pro.price_minor' => 7900,
|
||||||
'pos.plans.enterprise.price_minor' => 24900,
|
'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
|
public function test_subscribe_enterprise_charges_wallet_and_activates(): void
|
||||||
{
|
{
|
||||||
Http::fake(['*/debit' => Http::response(['id' => 1], 201)]);
|
Http::fake(['*/debit' => Http::response(['id' => 1], 201)]);
|
||||||
|
|||||||
Reference in New Issue
Block a user