Route event registrations through Ladill Pay API.
Deploy Ladill Events / deploy (push) Successful in 29s

Ticket and contribution checkouts use platform Pay for fees and wallet settlement, with legacy QREP-* fallback via Billing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-08 05:20:30 +00:00
co-authored by Cursor
parent 55e2ec71b3
commit c92ad91a06
7 changed files with 187 additions and 57 deletions
+3
View File
@@ -27,6 +27,9 @@ LADILL_SSO_CLIENT_SECRET=
BILLING_API_URL=https://ladill.com/api/billing BILLING_API_URL=https://ladill.com/api/billing
BILLING_API_KEY_EVENTS= BILLING_API_KEY_EVENTS=
PAY_API_URL=https://ladill.com/api/pay
PAY_API_KEY_EVENTS=
IDENTITY_API_URL=https://ladill.com/api IDENTITY_API_URL=https://ladill.com/api
IDENTITY_API_KEY_EVENTS= IDENTITY_API_KEY_EVENTS=
+1
View File
@@ -17,6 +17,7 @@ Pay into the one platform UserWallet. Public scans stay at `ladill.com/q/<code>`
|---|---| |---|---|
| `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` | `passport:client` on platform | | `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` | `passport:client` on platform |
| `BILLING_API_KEY_EVENTS` | platform `.env` + this app | | `BILLING_API_KEY_EVENTS` | platform `.env` + this app |
| `PAY_API_KEY_EVENTS` | platform `.env` (`PAY_API_KEY_EVENTS`) + this app — Ladill Pay checkout |
| `IDENTITY_API_KEY_EVENTS` | platform `.env` + this app | | `IDENTITY_API_KEY_EVENTS` | platform `.env` + this app |
## 1. Gitea repo + CI ## 1. Gitea repo + CI
+1
View File
@@ -13,6 +13,7 @@ class QrEventRegistration extends Model
public const STATUS_CANCELLED = 'cancelled'; public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [ protected $fillable = [
'pay_order_id',
'qr_code_id', 'qr_code_id',
'user_id', 'user_id',
'reference', 'reference',
+104 -57
View File
@@ -4,24 +4,19 @@ namespace App\Services\Events;
use App\Models\QrCode; use App\Models\QrCode;
use App\Models\QrEventRegistration; use App\Models\QrEventRegistration;
use App\Models\UserWalletTransaction; use App\Services\Billing\BillingClient;
use App\Services\Billing\PaystackService; use App\Services\Billing\PaystackService;
use App\Services\Billing\SmsService; use App\Services\Billing\SmsService;
use App\Services\Billing\WalletService; use App\Services\Pay\PayClient;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use RuntimeException; use RuntimeException;
class EventRegistrationService class EventRegistrationService
{ {
/** Platform fee on paid ticket sales. */
public const PLATFORM_FEE_RATE_TICKETING = 0.15;
/** Reduced platform fee on contribution / donation funds. */
public const PLATFORM_FEE_RATE_CONTRIBUTIONS = 0.09;
public function __construct( public function __construct(
private PayClient $pay,
private PaystackService $paystack, private PaystackService $paystack,
private WalletService $wallet, private BillingClient $billing,
private SmsService $sms, private SmsService $sms,
) {} ) {}
@@ -66,7 +61,6 @@ class EventRegistrationService
throw new RuntimeException('Enter a valid email address.'); throw new RuntimeException('Enter a valid email address.');
} }
// Capacity guard (0 = unlimited) — ticketing only.
if ($mode === 'ticketing') { if ($mode === 'ticketing') {
$capacity = (int) ($tier['capacity'] ?? 0); $capacity = (int) ($tier['capacity'] ?? 0);
if ($capacity > 0) { if ($capacity > 0) {
@@ -76,7 +70,7 @@ class EventRegistrationService
->where('status', QrEventRegistration::STATUS_CONFIRMED) ->where('status', QrEventRegistration::STATUS_CONFIRMED)
->count(); ->count();
if ($taken >= $capacity) { if ($taken >= $capacity) {
throw new RuntimeException('Sorry, "' . $tierName . '" is sold out.'); throw new RuntimeException('Sorry, "'.$tierName.'" is sold out.');
} }
} }
} }
@@ -100,54 +94,108 @@ class EventRegistrationService
$amountMinor = (int) round($priceGhs * 100); $amountMinor = (int) round($priceGhs * 100);
$registration = QrEventRegistration::create([ $registration = QrEventRegistration::create([
'qr_code_id' => $qrCode->id, 'qr_code_id' => $qrCode->id,
'user_id' => $qrCode->user_id, 'user_id' => $qrCode->user_id,
'reference' => 'QRE-' . strtoupper(Str::random(16)), 'reference' => 'QRE-'.strtoupper(Str::random(16)),
'badge_code' => $this->uniqueBadgeCode(), 'badge_code' => $this->uniqueBadgeCode(),
'tier_name' => $tierName, 'tier_name' => $tierName,
'amount_minor' => $amountMinor, 'amount_minor' => $amountMinor,
'currency' => $content['currency'] ?? 'GHS', 'currency' => $content['currency'] ?? 'GHS',
'attendee_name' => mb_substr($name, 0, 120), 'attendee_name' => mb_substr($name, 0, 120),
'attendee_email' => $email, 'attendee_email' => $email,
'attendee_phone' => trim((string) ($data['attendee_phone'] ?? '')) ?: null, 'attendee_phone' => trim((string) ($data['attendee_phone'] ?? '')) ?: null,
'badge_fields' => $badgeFields ?: null, 'badge_fields' => $badgeFields ?: null,
'status' => $amountMinor > 0 ? QrEventRegistration::STATUS_PENDING : QrEventRegistration::STATUS_CONFIRMED, 'status' => $amountMinor > 0 ? QrEventRegistration::STATUS_PENDING : QrEventRegistration::STATUS_CONFIRMED,
]); ]);
// Free ticket — done.
if ($amountMinor === 0) { if ($amountMinor === 0) {
$this->notifyConfirmed($registration->fresh('qrCode')); $this->notifyConfirmed($registration->fresh('qrCode'));
return ['registration' => $registration, 'paid' => false, 'checkout_url' => null]; return ['registration' => $registration, 'paid' => false, 'checkout_url' => null];
} }
// Paid ticket — start Paystack. $qrCode->loadMissing('user');
$reference = 'QREP-' . strtoupper(Str::random(16)); $feeTier = $mode === 'contributions' ? 'donations' : 'sales';
$registration->update(['payment_reference' => $reference]); $lineName = $mode === 'contributions'
? sprintf('%s — %s', $content['name'] ?? $qrCode->label, $tierName)
: sprintf('%s ticket — %s', $content['name'] ?? $qrCode->label, $tierName);
$paystackData = $this->paystack->initializeTransaction([ $payOrder = $this->pay->createCheckout([
'email' => $email, 'merchant' => $qrCode->user->public_id,
'amount' => $amountMinor, 'fee_tier' => $feeTier,
'currency' => $registration->currency, 'source_service' => 'events',
'reference' => $reference, 'source_ref' => (string) $qrCode->id,
'callback_url' => route('qr.public.event.callback', ['shortCode' => $qrCode->short_code]), 'callback_url' => route('qr.public.event.callback', ['shortCode' => $qrCode->short_code]),
'metadata' => [ 'customer_name' => $registration->attendee_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_id' => $registration->id,
'qr_code_id' => $qrCode->id, 'registration_reference' => $registration->reference,
'merchant_id' => $qrCode->user_id, 'qr_code_id' => $qrCode->id,
'attendee_name' => $registration->attendee_name, 'mode' => $mode,
'attendee_email' => $email,
], ],
]); ]);
$registration->update([
'pay_order_id' => $payOrder['id'] ?? null,
'payment_reference' => $payOrder['reference'],
]);
$checkoutUrl = (string) ($payOrder['checkout_url'] ?? '');
if ($checkoutUrl === '') {
throw new RuntimeException('Could not start checkout. Please try again.');
}
return [ return [
'registration' => $registration, 'registration' => $registration->fresh(),
'paid' => true, 'paid' => true,
'checkout_url' => 'https://checkout.paystack.com/' . $paystackData['access_code'], 'checkout_url' => $checkoutUrl,
]; ];
} }
public function complete(string $reference): QrEventRegistration public function complete(string $reference): QrEventRegistration
{
if (str_starts_with($reference, 'LP-')) {
return $this->completeLadillPay($reference);
}
return $this->completeLegacy($reference);
}
private function completeLadillPay(string $reference): QrEventRegistration
{
$registration = QrEventRegistration::where('payment_reference', $reference)
->where('status', QrEventRegistration::STATUS_PENDING)
->firstOrFail();
$payOrder = $this->pay->verify($reference);
$platformFeeGhs = ((int) ($payOrder['platform_fee_minor'] ?? 0)) / 100;
$registration->update([
'status' => QrEventRegistration::STATUS_CONFIRMED,
'paid_at' => now(),
'pay_order_id' => $payOrder['id'] ?? $registration->pay_order_id,
'metadata' => array_merge((array) $registration->metadata, [
'ladill_pay' => $payOrder,
'platform_fee_ghs' => $platformFeeGhs,
]),
]);
$this->notifyConfirmed($registration->fresh('qrCode'));
return $registration;
}
/** Legacy QREP-* references before Ladill Pay migration. */
private function completeLegacy(string $reference): QrEventRegistration
{ {
$registration = QrEventRegistration::where('payment_reference', $reference) $registration = QrEventRegistration::where('payment_reference', $reference)
->where('status', QrEventRegistration::STATUS_PENDING) ->where('status', QrEventRegistration::STATUS_PENDING)
@@ -160,37 +208,36 @@ class EventRegistrationService
throw new RuntimeException('Payment was not successful.'); throw new RuntimeException('Payment was not successful.');
} }
$feeRate = ($registration->qrCode?->content()['mode'] ?? 'ticketing') === 'contributions' $feeRate = ($registration->qrCode?->content()['mode'] ?? 'ticketing') === 'contributions' ? 0.09 : 0.15;
? self::PLATFORM_FEE_RATE_CONTRIBUTIONS
: self::PLATFORM_FEE_RATE_TICKETING;
$paidAmount = round(($data['amount'] ?? 0) / 100, 2); $paidAmount = round(($data['amount'] ?? 0) / 100, 2);
$platformFee = round($paidAmount * $feeRate, 2); $platformFee = round($paidAmount * $feeRate, 2);
$organizerAmount = round($paidAmount - $platformFee, 2);
$organizerAmountMinor = (int) round(($paidAmount - $platformFee) * 100);
$registration->update([ $registration->update([
'status' => QrEventRegistration::STATUS_CONFIRMED, 'status' => QrEventRegistration::STATUS_CONFIRMED,
'paid_at' => now(), 'paid_at' => now(),
'metadata' => array_merge((array) $registration->metadata, ['paystack' => $data, 'platform_fee_ghs' => $platformFee]), 'metadata' => array_merge((array) $registration->metadata, [
'paystack' => $data,
'platform_fee_ghs' => $platformFee,
'legacy' => true,
]),
]); ]);
$this->wallet->credit( $mode = ($registration->qrCode->content()['mode'] ?? 'ticketing') === 'contributions' ? 'contributions' : 'ticketing';
$registration->organizer, $this->billing->credit(
$organizerAmount, $registration->organizer->public_id,
UserWalletTransaction::SOURCE_SERVICE_TRANSFER, $organizerAmountMinor,
'events',
'pay',
$reference, $reference,
$registration->id,
sprintf( sprintf(
'%s — %s (%s)', '%s — %s (%s)',
($registration->qrCode->content()['mode'] ?? 'ticketing') === 'contributions' ? 'Event contribution' : 'Event ticket', $mode === 'contributions' ? 'Event contribution' : 'Event ticket',
$registration->qrCode->label, $registration->qrCode->label,
$registration->tier_name $registration->tier_name
), ),
[
'source' => 'qr_event',
'registration_id' => $registration->id,
'qr_code_id' => $registration->qr_code_id,
'platform_fee_ghs' => $platformFee,
]
); );
$this->notifyConfirmed($registration->fresh('qrCode')); $this->notifyConfirmed($registration->fresh('qrCode'));
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Services\Pay;
use Illuminate\Support\Facades\Http;
/**
* Client for the platform Ladill Pay HTTP API checkout and settlement for
* merchant↔buyer money. Paystack is the processor today; settlement lands in
* the one UserWallet via Platform Billing.
*/
class PayClient
{
private function base(): string
{
return rtrim((string) config('pay.api_url'), '/');
}
private function token(): string
{
return (string) (config('pay.api_key') ?? '');
}
/** @param array<string,mixed> $payload */
public function createCheckout(array $payload): array
{
$res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/checkouts', $payload);
$res->throw();
return (array) $res->json();
}
public function verify(string $reference): array
{
$res = Http::withToken($this->token())->acceptJson()->timeout(15)->post($this->base().'/checkouts/verify', [
'reference' => $reference,
]);
$res->throw();
return (array) $res->json();
}
public function show(string $reference): array
{
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->get($this->base().'/orders/'.$reference);
$res->throw();
return (array) $res->json();
}
}
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'api_url' => env('PAY_API_URL', 'https://ladill.com/api/pay'),
'api_key' => env('PAY_API_KEY_EVENTS'),
];
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('qr_event_registrations', function (Blueprint $table) {
$table->unsignedBigInteger('pay_order_id')->nullable()->after('id');
});
}
public function down(): void
{
Schema::table('qr_event_registrations', function (Blueprint $table) {
$table->dropColumn('pay_order_id');
});
}
};