Route event ticket payments through centralized Ladill Pay.
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:
isaacclad
2026-07-21 13:40:13 +00:00
co-authored by Cursor
parent 29da6f12a7
commit 7b20b71ea0
4 changed files with 231 additions and 66 deletions
@@ -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}
@@ -100,7 +101,7 @@ class EventRegistrationService
throw new RuntimeException('This organizer has reached the free monthly ticket limit. Ask them to upgrade Events Pro.');
}
$meetReturn = trim((string) ($data['meet_return'] ?? ''));
$meetReturn = trim((string) ($data['meet_return'] ?? ''));
$metadata = $meetReturn !== '' ? ['meet_return' => $meetReturn] : null;
$registration = QrEventRegistration::create([
@@ -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,
[
'title' => $lineName,
$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,
],
);
]);
$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'],