Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -174,12 +174,17 @@ class AccountController extends Controller
|
||||
);
|
||||
|
||||
$provider = (string) ($data['gateway_provider'] ?? '');
|
||||
if ($provider !== '' && $this->subscriptions->canUsePaymentGateway($account)) {
|
||||
if ($this->subscriptions->canUsePaymentGateway($account)) {
|
||||
$existing = PaymentGatewaySetting::query()->firstOrNew([
|
||||
'owner_ref' => $account->public_id,
|
||||
]);
|
||||
$existing->provider = $provider;
|
||||
$existing->is_active = $request->boolean('gateway_is_active', true);
|
||||
if ($provider === '' && ! $existing->exists) {
|
||||
return redirect()->route('account.settings')->with('success', 'Settings saved.');
|
||||
}
|
||||
if ($provider !== '') {
|
||||
$existing->provider = $provider;
|
||||
}
|
||||
$existing->is_active = $provider !== '' && $request->boolean('gateway_is_active');
|
||||
if (filled($data['gateway_public_key'] ?? null)) {
|
||||
$existing->public_key = $data['gateway_public_key'];
|
||||
}
|
||||
|
||||
@@ -39,12 +39,13 @@ 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, self::PROVIDER_HUBTEL => $public !== '' && $secret !== '',
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,30 +133,54 @@ class EventRegistrationService
|
||||
$feeTier = $mode === 'contributions' ? 'donations' : 'sales';
|
||||
$callbackUrl = LadillLink::path($qrCode->short_code, 'register/callback');
|
||||
|
||||
$payOrder = $this->pay->createCheckout([
|
||||
'merchant' => $qrCode->user->public_id,
|
||||
'fee_tier' => $feeTier,
|
||||
'source_service' => 'events',
|
||||
'source_ref' => (string) $qrCode->id,
|
||||
'callback_url' => $callbackUrl,
|
||||
'customer_name' => $name,
|
||||
'customer_email' => $email,
|
||||
'customer_phone' => $registration->attendee_phone,
|
||||
'line_items' => [
|
||||
[
|
||||
$usesOwnerGateway = $this->gateway->shouldUseOwnerGateway($qrCode->user);
|
||||
$payOrder = null;
|
||||
if ($usesOwnerGateway) {
|
||||
try {
|
||||
$payOrder = $this->gateway->initializeCheckout(
|
||||
$qrCode->user,
|
||||
$amountMinor,
|
||||
(string) ($content['currency'] ?? 'GHS'),
|
||||
(string) config('pay.mini_checkout_email', 'pay@ladill.com'),
|
||||
$callbackUrl,
|
||||
'EVTP-'.strtoupper(Str::random(16)),
|
||||
[
|
||||
'title' => $lineName,
|
||||
'registration_id' => $registration->id,
|
||||
'registration_reference' => $registration->reference,
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'mode' => $mode,
|
||||
'attendee_email' => $email,
|
||||
],
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$usesOwnerGateway = false;
|
||||
}
|
||||
}
|
||||
if (! $usesOwnerGateway) {
|
||||
$payOrder = $this->pay->createCheckout([
|
||||
'merchant' => $qrCode->user->public_id,
|
||||
'fee_tier' => $feeTier,
|
||||
'source_service' => 'events',
|
||||
'source_ref' => (string) $qrCode->id,
|
||||
'callback_url' => $callbackUrl,
|
||||
'customer_name' => $name,
|
||||
'customer_email' => $email,
|
||||
'customer_phone' => $registration->attendee_phone,
|
||||
'line_items' => [[
|
||||
'name' => $lineName,
|
||||
'unit_price_minor' => $amountMinor,
|
||||
'quantity' => 1,
|
||||
]],
|
||||
'metadata' => [
|
||||
'registration_id' => $registration->id,
|
||||
'registration_reference' => $registration->reference,
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'mode' => $mode,
|
||||
'attendee_email' => $email,
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'registration_id' => $registration->id,
|
||||
'registration_reference' => $registration->reference,
|
||||
'qr_code_id' => $qrCode->id,
|
||||
'mode' => $mode,
|
||||
'attendee_email' => $email,
|
||||
],
|
||||
]);
|
||||
]);
|
||||
}
|
||||
|
||||
$checkoutUrl = (string) ($payOrder['checkout_url'] ?? '');
|
||||
if ($checkoutUrl === '') {
|
||||
@@ -164,10 +188,11 @@ class EventRegistrationService
|
||||
}
|
||||
|
||||
$registration->update([
|
||||
'pay_order_id' => $payOrder['id'] ?? null,
|
||||
'pay_order_id' => $usesOwnerGateway ? null : ($payOrder['id'] ?? null),
|
||||
'payment_reference' => $payOrder['reference'],
|
||||
'metadata' => array_merge((array) $registration->metadata, [
|
||||
'ladill_pay_init' => [
|
||||
($usesOwnerGateway ? 'merchant_gateway_init' : 'ladill_pay_init') => [
|
||||
'provider' => $payOrder['provider'] ?? 'ladill_pay',
|
||||
'platform_fee_minor' => $payOrder['platform_fee_minor'] ?? null,
|
||||
'merchant_amount_minor' => $payOrder['merchant_amount_minor'] ?? null,
|
||||
],
|
||||
|
||||
@@ -19,11 +19,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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,7 +57,12 @@ class MerchantGatewayService
|
||||
*/
|
||||
public function verify(User|string $owner, string $reference): array
|
||||
{
|
||||
$setting = $this->requireConfigured($owner);
|
||||
// Do not re-check the current plan here: an owner-gateway payment
|
||||
// initiated before a downgrade must remain verifiable.
|
||||
$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),
|
||||
|
||||
@@ -272,9 +272,31 @@
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 space-y-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-slate-900">Online payments</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Ticket and contribution payments are processed by Ladill Pay (Paystack). Funds settle to your Ladill wallet — no API keys required.</p>
|
||||
<p class="mt-1 text-sm text-slate-500">Ladill Pay is the recommended default and needs no API keys. Pro and Business may optionally select an owner gateway.</p>
|
||||
</div>
|
||||
<p class="rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">Ladill Pay is active for paid tickets and contributions on all plans.</p>
|
||||
<p class="rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">Ladill Pay is active on all plans and remains the automatic fallback.</p>
|
||||
@if ($canUsePaymentGateway ?? false)
|
||||
<select name="gateway_provider" class="w-full rounded-lg border-slate-200 text-sm">
|
||||
@php $selectedGateway = old('gateway_provider', $gateway?->is_active ? $gateway?->provider : ''); @endphp
|
||||
<option value="">Ladill Pay (recommended)</option>
|
||||
@foreach (['paystack' => 'Paystack', 'flutterwave' => 'Flutterwave', 'hubtel' => 'Hubtel'] as $value => $label)
|
||||
<option value="{{ $value }}" @selected($selectedGateway === $value)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input name="gateway_public_key" value="" placeholder="{{ $gateway?->public_key ? 'Public key saved — leave blank to keep' : 'Public key / merchant account' }}" class="rounded-lg border-slate-200 text-sm">
|
||||
<input type="password" name="gateway_secret_key" value="" placeholder="{{ $gateway?->secret_key ? 'Secret saved — leave blank to keep' : 'Secret / API key' }}" autocomplete="new-password" class="rounded-lg border-slate-200 text-sm">
|
||||
</div>
|
||||
<input type="password" name="gateway_webhook_secret" value="" placeholder="Webhook secret (optional)" autocomplete="new-password" class="w-full rounded-lg border-slate-200 text-sm">
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<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 ?? false)) class="rounded border-slate-300 text-indigo-600">
|
||||
Use my gateway instead of Ladill Pay
|
||||
</label>
|
||||
<p class="text-xs text-slate-500">Incomplete or invalid credentials automatically fall back to Ladill Pay.</p>
|
||||
@else
|
||||
<p class="rounded-lg bg-indigo-50 px-3 py-2 text-sm text-indigo-800">Free uses Ladill Pay only. Upgrade to Pro to optionally connect your own gateway.</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">
|
||||
|
||||
@@ -103,4 +103,26 @@ class EventsProTest extends TestCase
|
||||
$this->assertTrue($svc->canUsePaymentGateway($user));
|
||||
$this->assertTrue(app(\App\Services\Payments\MerchantGatewayService::class)->isConfigured($user));
|
||||
}
|
||||
|
||||
public function test_invalid_credentials_and_downgrade_disable_owner_gateway_without_deleting_keys(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
ProSubscription::create([
|
||||
'user_id' => $user->id, 'plan' => 'enterprise', 'status' => 'active',
|
||||
'price_minor' => 14900, 'current_period_end' => now()->addMonth(),
|
||||
]);
|
||||
$setting = \App\Models\PaymentGatewaySetting::create([
|
||||
'owner_ref' => $user->public_id, 'provider' => 'paystack',
|
||||
'public_key' => 'bad', 'secret_key' => 'bad', 'is_active' => true,
|
||||
]);
|
||||
$gateway = app(\App\Services\Payments\MerchantGatewayService::class);
|
||||
$this->assertFalse($gateway->shouldUseOwnerGateway($user));
|
||||
|
||||
$setting->update(['public_key' => 'pk_test_saved', 'secret_key' => 'sk_test_saved']);
|
||||
$this->assertTrue($gateway->shouldUseOwnerGateway($user));
|
||||
|
||||
ProSubscription::where('user_id', $user->id)->update(['status' => 'past_due']);
|
||||
$this->assertFalse($gateway->shouldUseOwnerGateway($user->fresh()));
|
||||
$this->assertDatabaseHas('payment_gateway_settings', ['id' => $setting->id, 'is_active' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user