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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-21 19:47:10 +00:00
co-authored by Cursor
parent 837c249634
commit 1bcbdbfdb1
6 changed files with 119 additions and 37 deletions
@@ -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,
]);
if ($provider === '' && ! $existing->exists) {
return redirect()->route('account.settings')->with('success', 'Settings saved.');
}
if ($provider !== '') {
$existing->provider = $provider;
$existing->is_active = $request->boolean('gateway_is_active', true);
}
$existing->is_active = $provider !== '' && $request->boolean('gateway_is_active');
if (filled($data['gateway_public_key'] ?? null)) {
$existing->public_key = $data['gateway_public_key'];
}
+6 -5
View File
@@ -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,6 +133,31 @@ class EventRegistrationService
$feeTier = $mode === 'contributions' ? 'donations' : 'sales';
$callbackUrl = LadillLink::path($qrCode->short_code, 'register/callback');
$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,
@@ -142,13 +167,11 @@ class EventRegistrationService
'customer_name' => $name,
'customer_email' => $email,
'customer_phone' => $registration->attendee_phone,
'line_items' => [
[
'line_items' => [[
'name' => $lineName,
'unit_price_minor' => $amountMinor,
'quantity' => 1,
],
],
]],
'metadata' => [
'registration_id' => $registration->id,
'registration_reference' => $registration->reference,
@@ -157,6 +180,7 @@ class EventRegistrationService
'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),
+24 -2
View File
@@ -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">
+22
View File
@@ -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]);
}
}