Route event ticket payments through centralized Ladill Pay.
Deploy Ladill Events / deploy (push) Successful in 45s
Deploy Ladill Events / deploy (push) Successful in 45s
Replace per-owner gateway checkout with platform Paystack via the Pay API, drop owner key requirements from settings, and add coverage for init and idempotent completion. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,8 +5,8 @@ namespace App\Services\Events;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Services\Billing\SmsService;
|
||||
use App\Services\Events\EventEmailService;
|
||||
use App\Services\Meet\EventMeetAccessService;
|
||||
use App\Services\Pay\PayClient;
|
||||
use App\Services\Payments\MerchantGatewayService;
|
||||
use App\Support\LadillLink;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -15,6 +15,7 @@ use RuntimeException;
|
||||
class EventRegistrationService
|
||||
{
|
||||
public function __construct(
|
||||
private PayClient $pay,
|
||||
private MerchantGatewayService $gateway,
|
||||
private SubscriptionService $subscriptions,
|
||||
private SmsService $sms,
|
||||
@@ -24,7 +25,7 @@ class EventRegistrationService
|
||||
|
||||
/**
|
||||
* Register an attendee. Free tiers confirm instantly; paid tiers return a
|
||||
* merchant-gateway checkout URL and stay pending until payment completes.
|
||||
* Ladill Pay checkout URL and stay pending until payment completes.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return array{registration: QrEventRegistration, paid: bool, checkout_url: ?string}
|
||||
@@ -125,47 +126,111 @@ class EventRegistrationService
|
||||
return ['registration' => $registration, 'paid' => false, 'checkout_url' => null];
|
||||
}
|
||||
|
||||
$qrCode->loadMissing('user');
|
||||
$lineName = $mode === 'contributions'
|
||||
? sprintf('%s — %s', $content['name'] ?? $qrCode->label, $tierName)
|
||||
: sprintf('%s ticket — %s', $content['name'] ?? $qrCode->label, $tierName);
|
||||
|
||||
// Ticket buyers (not till staff): gateways get the attendee email so
|
||||
// receipts and 3DS challenges belong to the person purchasing.
|
||||
$buyerEmail = $email;
|
||||
if ($buyerEmail === '' || ! str_contains($buyerEmail, '@')) {
|
||||
$buyerEmail = 'buyer+'.($registration->reference).'@checkout.ladill.local';
|
||||
}
|
||||
$feeTier = $mode === 'contributions' ? 'donations' : 'sales';
|
||||
$callbackUrl = LadillLink::path($qrCode->short_code, 'register/callback');
|
||||
|
||||
$checkout = $this->gateway->initializeCheckout(
|
||||
$qrCode->user,
|
||||
$amountMinor,
|
||||
(string) ($registration->currency ?? 'GHS'),
|
||||
$buyerEmail,
|
||||
LadillLink::path($qrCode->short_code, 'register/callback'),
|
||||
$registration->reference,
|
||||
$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' => [
|
||||
[
|
||||
'title' => $lineName,
|
||||
'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,
|
||||
],
|
||||
);
|
||||
]);
|
||||
|
||||
$checkoutUrl = (string) ($payOrder['checkout_url'] ?? '');
|
||||
if ($checkoutUrl === '') {
|
||||
throw new RuntimeException('Could not start checkout. Please try again.');
|
||||
}
|
||||
|
||||
$registration->update([
|
||||
'payment_reference' => $checkout['reference'],
|
||||
'pay_order_id' => $payOrder['id'] ?? null,
|
||||
'payment_reference' => $payOrder['reference'],
|
||||
'metadata' => array_merge((array) $registration->metadata, [
|
||||
'ladill_pay_init' => [
|
||||
'platform_fee_minor' => $payOrder['platform_fee_minor'] ?? null,
|
||||
'merchant_amount_minor' => $payOrder['merchant_amount_minor'] ?? null,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
return [
|
||||
'registration' => $registration->fresh(),
|
||||
'paid' => true,
|
||||
'checkout_url' => $checkout['checkout_url'],
|
||||
'checkout_url' => $checkoutUrl,
|
||||
];
|
||||
}
|
||||
|
||||
public function complete(string $reference): QrEventRegistration
|
||||
{
|
||||
if (str_starts_with($reference, 'LP-')) {
|
||||
return $this->completeLadillPay($reference);
|
||||
}
|
||||
|
||||
return $this->completeLegacyGateway($reference);
|
||||
}
|
||||
|
||||
private function completeLadillPay(string $reference): QrEventRegistration
|
||||
{
|
||||
$registration = QrEventRegistration::where('payment_reference', $reference)
|
||||
->with('qrCode.user')
|
||||
->firstOrFail();
|
||||
|
||||
if ($registration->status === QrEventRegistration::STATUS_CONFIRMED) {
|
||||
return $registration;
|
||||
}
|
||||
|
||||
if ($registration->status !== QrEventRegistration::STATUS_PENDING) {
|
||||
throw new RuntimeException('This registration can no longer be completed.');
|
||||
}
|
||||
|
||||
$payOrder = $this->pay->verify($reference);
|
||||
|
||||
$verifiedMinor = (int) ($payOrder['amount_minor'] ?? 0);
|
||||
if ($verifiedMinor > 0 && $verifiedMinor < $registration->amount_minor) {
|
||||
$registration->update(['status' => QrEventRegistration::STATUS_FAILED]);
|
||||
throw new RuntimeException('Payment amount did not match the registration total.');
|
||||
}
|
||||
|
||||
$amountMinor = $verifiedMinor > 0 ? $verifiedMinor : $registration->amount_minor;
|
||||
|
||||
$registration->update([
|
||||
'status' => QrEventRegistration::STATUS_CONFIRMED,
|
||||
'paid_at' => now(),
|
||||
'amount_minor' => $amountMinor,
|
||||
'metadata' => array_merge((array) $registration->metadata, [
|
||||
'ladill_pay' => $payOrder,
|
||||
'platform_fee_ghs' => round(($payOrder['platform_fee_minor'] ?? 0) / 100, 2),
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->notifyConfirmed($registration->fresh('qrCode'));
|
||||
|
||||
return $registration;
|
||||
}
|
||||
|
||||
/** Legacy merchant-gateway references before Ladill Pay migration. */
|
||||
private function completeLegacyGateway(string $reference): QrEventRegistration
|
||||
{
|
||||
$registration = QrEventRegistration::where('payment_reference', $reference)
|
||||
->where('status', QrEventRegistration::STATUS_PENDING)
|
||||
@@ -179,10 +244,16 @@ class EventRegistrationService
|
||||
throw new RuntimeException('Payment was not successful.');
|
||||
}
|
||||
|
||||
$verifiedMinor = (int) ($result['amount_minor'] ?: 0);
|
||||
if ($verifiedMinor > 0 && $verifiedMinor < $registration->amount_minor) {
|
||||
$registration->update(['status' => QrEventRegistration::STATUS_FAILED]);
|
||||
throw new RuntimeException('Payment amount did not match the registration total.');
|
||||
}
|
||||
|
||||
$registration->update([
|
||||
'status' => QrEventRegistration::STATUS_CONFIRMED,
|
||||
'paid_at' => now(),
|
||||
'amount_minor' => (int) ($result['amount_minor'] ?: $registration->amount_minor),
|
||||
'amount_minor' => $verifiedMinor > 0 ? $verifiedMinor : $registration->amount_minor,
|
||||
'metadata' => array_merge((array) $registration->metadata, [
|
||||
'merchant_gateway' => [
|
||||
'provider' => $result['provider'],
|
||||
|
||||
@@ -18,7 +18,10 @@ $root = config('app.platform_domain', 'ladill.com');
|
||||
|
||||
return [
|
||||
'apps' => [
|
||||
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
|
||||
['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'],
|
||||
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
|
||||
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
|
||||
['name' => 'Woo Manager', 'url' => 'https://woo.'.$root.'/sso/connect?redirect='.urlencode('https://woo.'.$root.'/dashboard'), 'icon' => 'woomanager.svg'],
|
||||
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
|
||||
['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'],
|
||||
@@ -29,6 +32,7 @@ return [
|
||||
['name' => 'Link', 'url' => 'https://link.'.$root.'/sso/connect?redirect='.urlencode('https://link.'.$root.'/dashboard'), 'icon' => 'link.svg'],
|
||||
['name' => 'Frontdesk', 'url' => 'https://frontdesk.'.$root.'/sso/connect?redirect='.urlencode('https://frontdesk.'.$root.'/dashboard'), 'icon' => 'frontdesk.svg'],
|
||||
['name' => 'Care', 'url' => 'https://care.'.$root.'/sso/connect?redirect='.urlencode('https://care.'.$root.'/dashboard'), 'icon' => 'care.svg'],
|
||||
['name' => 'One', 'url' => 'https://one.'.$root.'/sso/connect?redirect='.urlencode('https://one.'.$root.'/dashboard'), 'icon' => 'one.svg'],
|
||||
['name' => 'Queue', 'url' => 'https://queue.'.$root.'/sso/connect?redirect='.urlencode('https://queue.'.$root.'/dashboard'), 'icon' => 'queue.svg'],
|
||||
['name' => 'SMS', 'url' => 'https://sms.'.$root, 'icon' => 'sms.svg'],
|
||||
['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'],
|
||||
|
||||
@@ -271,50 +271,10 @@
|
||||
|
||||
<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">Payment gateway</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Connect Paystack, Flutterwave, or Hubtel. Ticket and contribution payments go 100% to you — 0% Ladill platform fee. Pro & Business only.</p>
|
||||
<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>
|
||||
</div>
|
||||
@if ($canUsePaymentGateway ?? false)
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700">Provider</label>
|
||||
<select name="gateway_provider" class="mt-1 w-full rounded-lg border-slate-200">
|
||||
<option value="">Select a provider</option>
|
||||
<option value="paystack" @selected(old('gateway_provider', $gateway?->provider) === 'paystack')>Paystack</option>
|
||||
<option value="flutterwave" @selected(old('gateway_provider', $gateway?->provider) === 'flutterwave')>Flutterwave</option>
|
||||
<option value="hubtel" @selected(old('gateway_provider', $gateway?->provider) === 'hubtel')>Hubtel</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700">Public key / Merchant account</label>
|
||||
<input type="text" name="gateway_public_key" value="{{ old('gateway_public_key') }}" placeholder="{{ $gateway?->public_key ? '•••• saved — leave blank to keep' : 'pk_live_…' }}" class="mt-1 w-full rounded-lg border-slate-200">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700">Secret / API key</label>
|
||||
<input type="password" name="gateway_secret_key" value="" placeholder="{{ $gateway?->secret_key ? '•••• saved — leave blank to keep' : 'sk_live_…' }}" class="mt-1 w-full rounded-lg border-slate-200" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700">Webhook secret (optional)</label>
|
||||
<input type="password" name="gateway_webhook_secret" value="" placeholder="{{ $gateway?->webhook_secret ? '•••• saved — leave blank to keep' : 'Optional' }}" class="mt-1 w-full rounded-lg border-slate-200" autocomplete="new-password">
|
||||
</div>
|
||||
<label class="inline-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 ?? true)) class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
Gateway enabled for paid tickets and contributions
|
||||
</label>
|
||||
@if ($gateway?->isConfigured())
|
||||
<p class="rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">Gateway connected ({{ ucfirst($gateway->provider) }}).</p>
|
||||
@else
|
||||
<p class="rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800">Paid checkouts stay disabled until a gateway is connected.</p>
|
||||
@endif
|
||||
@else
|
||||
<div class="rounded-lg 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="mt-1 text-indigo-800/80">Upgrade to Pro or Business to connect Paystack, Flutterwave, or Hubtel for paid tickets and contributions with 0% Ladill fee.</p>
|
||||
<a href="{{ route('events.pro.index') }}" class="mt-3 inline-flex text-sm font-semibold text-indigo-700 hover:text-indigo-900">View plans →</a>
|
||||
</div>
|
||||
@endif
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Models\User;
|
||||
use App\Services\Events\EventRegistrationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LadillPayEventRegistrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'pay.api_url' => 'https://ladill.com/api/pay',
|
||||
'pay.api_key' => 'events-pay-test-key',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_paid_registration_uses_ladill_pay_without_merchant_gateway(): void
|
||||
{
|
||||
Http::fake([
|
||||
'https://ladill.com/api/pay/checkouts' => Http::response([
|
||||
'id' => 55,
|
||||
'reference' => 'LP-EVENTTEST0001',
|
||||
'checkout_url' => 'https://checkout.paystack.com/events-test',
|
||||
'platform_fee_minor' => 82,
|
||||
'merchant_amount_minor' => 4918,
|
||||
'provider' => 'paystack',
|
||||
], 201),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create(['public_id' => 'usr_evt_'.uniqid()]);
|
||||
|
||||
$qr = QrCode::create([
|
||||
'user_id' => $user->id,
|
||||
'short_code' => 'evt'.strtolower(substr(uniqid(), -6)),
|
||||
'type' => QrCode::TYPE_EVENT,
|
||||
'label' => 'Demo Event',
|
||||
'is_active' => true,
|
||||
'payload' => [
|
||||
'content' => [
|
||||
'name' => 'Demo Event',
|
||||
'mode' => 'ticketing',
|
||||
'registration_open' => true,
|
||||
'currency' => 'GHS',
|
||||
'tiers' => [
|
||||
['name' => 'General', 'price' => 50, 'capacity' => 100],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = app(EventRegistrationService::class)->register($qr, [
|
||||
'tier' => 'General',
|
||||
'attendee_name' => 'Ada Lovelace',
|
||||
'attendee_email' => 'ada@example.com',
|
||||
'attendee_phone' => '0244111222',
|
||||
]);
|
||||
|
||||
$this->assertTrue($result['paid']);
|
||||
$this->assertSame('https://checkout.paystack.com/events-test', $result['checkout_url']);
|
||||
$this->assertSame(QrEventRegistration::STATUS_PENDING, $result['registration']->status);
|
||||
$this->assertSame('LP-EVENTTEST0001', $result['registration']->payment_reference);
|
||||
|
||||
Http::assertSent(function ($request) use ($user) {
|
||||
if (! str_ends_with($request->url(), '/api/pay/checkouts')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $request->hasHeader('Authorization', 'Bearer events-pay-test-key')
|
||||
&& ($request['merchant'] ?? '') === $user->public_id
|
||||
&& ($request['source_service'] ?? '') === 'events'
|
||||
&& ($request['fee_tier'] ?? '') === 'sales'
|
||||
&& ($request['customer_email'] ?? '') === 'ada@example.com';
|
||||
});
|
||||
}
|
||||
|
||||
public function test_complete_is_idempotent_for_confirmed_registration(): void
|
||||
{
|
||||
Http::fake([
|
||||
'https://ladill.com/api/pay/checkouts/verify' => Http::response([
|
||||
'reference' => 'LP-IDEMP00001',
|
||||
'status' => 'paid',
|
||||
'amount_minor' => 5000,
|
||||
'platform_fee_minor' => 75,
|
||||
'merchant_amount_minor' => 4925,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create(['public_id' => 'usr_idem_'.uniqid()]);
|
||||
$qr = QrCode::create([
|
||||
'user_id' => $user->id,
|
||||
'short_code' => 'idm'.strtolower(substr(uniqid(), -6)),
|
||||
'type' => QrCode::TYPE_EVENT,
|
||||
'label' => 'Idempotent Event',
|
||||
'is_active' => true,
|
||||
'payload' => [
|
||||
'content' => ['name' => 'Idempotent Event'],
|
||||
],
|
||||
]);
|
||||
|
||||
$registration = QrEventRegistration::create([
|
||||
'qr_code_id' => $qr->id,
|
||||
'user_id' => $user->id,
|
||||
'reference' => 'QRE-IDEMP00001',
|
||||
'badge_code' => 'BADGE01',
|
||||
'tier_name' => 'General',
|
||||
'amount_minor' => 5000,
|
||||
'currency' => 'GHS',
|
||||
'attendee_name' => 'Test User',
|
||||
'attendee_email' => 'test@example.com',
|
||||
'status' => QrEventRegistration::STATUS_CONFIRMED,
|
||||
'payment_reference' => 'LP-IDEMP00001',
|
||||
'paid_at' => now(),
|
||||
]);
|
||||
|
||||
$completed = app(EventRegistrationService::class)->complete('LP-IDEMP00001');
|
||||
|
||||
$this->assertSame(QrEventRegistration::STATUS_CONFIRMED, $completed->status);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user