Route event registrations through Ladill Pay API.
Deploy Ladill Events / deploy (push) Successful in 29s
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:
@@ -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=
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -114,40 +108,94 @@ class EventRegistrationService
|
|||||||
'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]),
|
||||||
|
'customer_name' => $registration->attendee_name,
|
||||||
|
'customer_email' => $email,
|
||||||
|
'customer_phone' => $registration->attendee_phone,
|
||||||
|
'line_items' => [
|
||||||
|
[
|
||||||
|
'name' => $lineName,
|
||||||
|
'unit_price_minor' => $amountMinor,
|
||||||
|
'quantity' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'registration_id' => $registration->id,
|
'registration_id' => $registration->id,
|
||||||
|
'registration_reference' => $registration->reference,
|
||||||
'qr_code_id' => $qrCode->id,
|
'qr_code_id' => $qrCode->id,
|
||||||
'merchant_id' => $qrCode->user_id,
|
'mode' => $mode,
|
||||||
'attendee_name' => $registration->attendee_name,
|
|
||||||
'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'));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'api_url' => env('PAY_API_URL', 'https://ladill.com/api/pay'),
|
||||||
|
'api_key' => env('PAY_API_KEY_EVENTS'),
|
||||||
|
];
|
||||||
+22
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user