Make POS payment routing plan-aware.
Deploy Ladill POS / deploy (push) Successful in 37s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-21 19:47:11 +00:00
co-authored by Cursor
parent ca08f303c0
commit 4dcc46e319
4 changed files with 136 additions and 27 deletions
+7 -5
View File
@@ -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
+15 -10
View File
@@ -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 &amp; 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
+44
View File
@@ -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)]);