diff --git a/.env.example b/.env.example index 41cc1f1..e0a8fce 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,9 @@ LADILL_SSO_CLIENT_SECRET= BILLING_API_URL=https://ladill.com/api/billing 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_KEY_EVENTS= diff --git a/DEPLOY.md b/DEPLOY.md index 72f85eb..3fccd6e 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -17,6 +17,7 @@ Pay into the one platform UserWallet. Public scans stay at `ladill.com/q/` |---|---| | `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` | `passport:client` on platform | | `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 | ## 1. Gitea repo + CI diff --git a/app/Models/QrEventRegistration.php b/app/Models/QrEventRegistration.php index 05b6f76..6ed9568 100644 --- a/app/Models/QrEventRegistration.php +++ b/app/Models/QrEventRegistration.php @@ -13,6 +13,7 @@ class QrEventRegistration extends Model public const STATUS_CANCELLED = 'cancelled'; protected $fillable = [ + 'pay_order_id', 'qr_code_id', 'user_id', 'reference', diff --git a/app/Services/Events/EventRegistrationService.php b/app/Services/Events/EventRegistrationService.php index 7866928..d3dd126 100644 --- a/app/Services/Events/EventRegistrationService.php +++ b/app/Services/Events/EventRegistrationService.php @@ -4,24 +4,19 @@ namespace App\Services\Events; use App\Models\QrCode; use App\Models\QrEventRegistration; -use App\Models\UserWalletTransaction; +use App\Services\Billing\BillingClient; use App\Services\Billing\PaystackService; use App\Services\Billing\SmsService; -use App\Services\Billing\WalletService; +use App\Services\Pay\PayClient; use Illuminate\Support\Str; use RuntimeException; 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( + private PayClient $pay, private PaystackService $paystack, - private WalletService $wallet, + private BillingClient $billing, private SmsService $sms, ) {} @@ -66,7 +61,6 @@ class EventRegistrationService throw new RuntimeException('Enter a valid email address.'); } - // Capacity guard (0 = unlimited) — ticketing only. if ($mode === 'ticketing') { $capacity = (int) ($tier['capacity'] ?? 0); if ($capacity > 0) { @@ -76,7 +70,7 @@ class EventRegistrationService ->where('status', QrEventRegistration::STATUS_CONFIRMED) ->count(); 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); $registration = QrEventRegistration::create([ - 'qr_code_id' => $qrCode->id, - 'user_id' => $qrCode->user_id, - 'reference' => 'QRE-' . strtoupper(Str::random(16)), - 'badge_code' => $this->uniqueBadgeCode(), - 'tier_name' => $tierName, - 'amount_minor' => $amountMinor, - 'currency' => $content['currency'] ?? 'GHS', - 'attendee_name' => mb_substr($name, 0, 120), + 'qr_code_id' => $qrCode->id, + 'user_id' => $qrCode->user_id, + 'reference' => 'QRE-'.strtoupper(Str::random(16)), + 'badge_code' => $this->uniqueBadgeCode(), + 'tier_name' => $tierName, + 'amount_minor' => $amountMinor, + 'currency' => $content['currency'] ?? 'GHS', + 'attendee_name' => mb_substr($name, 0, 120), 'attendee_email' => $email, 'attendee_phone' => trim((string) ($data['attendee_phone'] ?? '')) ?: null, - 'badge_fields' => $badgeFields ?: null, - 'status' => $amountMinor > 0 ? QrEventRegistration::STATUS_PENDING : QrEventRegistration::STATUS_CONFIRMED, + 'badge_fields' => $badgeFields ?: null, + 'status' => $amountMinor > 0 ? QrEventRegistration::STATUS_PENDING : QrEventRegistration::STATUS_CONFIRMED, ]); - // Free ticket — done. if ($amountMinor === 0) { $this->notifyConfirmed($registration->fresh('qrCode')); return ['registration' => $registration, 'paid' => false, 'checkout_url' => null]; } - // Paid ticket — start Paystack. - $reference = 'QREP-' . strtoupper(Str::random(16)); - $registration->update(['payment_reference' => $reference]); + $qrCode->loadMissing('user'); + $feeTier = $mode === 'contributions' ? 'donations' : 'sales'; + $lineName = $mode === 'contributions' + ? sprintf('%s — %s', $content['name'] ?? $qrCode->label, $tierName) + : sprintf('%s ticket — %s', $content['name'] ?? $qrCode->label, $tierName); - $paystackData = $this->paystack->initializeTransaction([ - 'email' => $email, - 'amount' => $amountMinor, - 'currency' => $registration->currency, - 'reference' => $reference, + $payOrder = $this->pay->createCheckout([ + 'merchant' => $qrCode->user->public_id, + 'fee_tier' => $feeTier, + 'source_service' => 'events', + 'source_ref' => (string) $qrCode->id, '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, - 'qr_code_id' => $qrCode->id, - 'merchant_id' => $qrCode->user_id, - 'attendee_name' => $registration->attendee_name, - 'attendee_email' => $email, + 'registration_reference' => $registration->reference, + 'qr_code_id' => $qrCode->id, + 'mode' => $mode, ], ]); + $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 [ - 'registration' => $registration, - 'paid' => true, - 'checkout_url' => 'https://checkout.paystack.com/' . $paystackData['access_code'], + 'registration' => $registration->fresh(), + 'paid' => true, + 'checkout_url' => $checkoutUrl, ]; } 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) ->where('status', QrEventRegistration::STATUS_PENDING) @@ -160,37 +208,36 @@ class EventRegistrationService throw new RuntimeException('Payment was not successful.'); } - $feeRate = ($registration->qrCode?->content()['mode'] ?? 'ticketing') === 'contributions' - ? self::PLATFORM_FEE_RATE_CONTRIBUTIONS - : self::PLATFORM_FEE_RATE_TICKETING; - + $feeRate = ($registration->qrCode?->content()['mode'] ?? 'ticketing') === 'contributions' ? 0.09 : 0.15; $paidAmount = round(($data['amount'] ?? 0) / 100, 2); $platformFee = round($paidAmount * $feeRate, 2); - $organizerAmount = round($paidAmount - $platformFee, 2); + + $organizerAmountMinor = (int) round(($paidAmount - $platformFee) * 100); $registration->update([ - 'status' => QrEventRegistration::STATUS_CONFIRMED, - 'paid_at' => now(), - 'metadata' => array_merge((array) $registration->metadata, ['paystack' => $data, 'platform_fee_ghs' => $platformFee]), + 'status' => QrEventRegistration::STATUS_CONFIRMED, + 'paid_at' => now(), + 'metadata' => array_merge((array) $registration->metadata, [ + 'paystack' => $data, + 'platform_fee_ghs' => $platformFee, + 'legacy' => true, + ]), ]); - $this->wallet->credit( - $registration->organizer, - $organizerAmount, - UserWalletTransaction::SOURCE_SERVICE_TRANSFER, + $mode = ($registration->qrCode->content()['mode'] ?? 'ticketing') === 'contributions' ? 'contributions' : 'ticketing'; + $this->billing->credit( + $registration->organizer->public_id, + $organizerAmountMinor, + 'events', + 'pay', $reference, + $registration->id, sprintf( '%s — %s (%s)', - ($registration->qrCode->content()['mode'] ?? 'ticketing') === 'contributions' ? 'Event contribution' : 'Event ticket', + $mode === 'contributions' ? 'Event contribution' : 'Event ticket', $registration->qrCode->label, $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')); diff --git a/app/Services/Pay/PayClient.php b/app/Services/Pay/PayClient.php new file mode 100644 index 0000000..19c94b5 --- /dev/null +++ b/app/Services/Pay/PayClient.php @@ -0,0 +1,50 @@ + $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(); + } +} diff --git a/config/pay.php b/config/pay.php new file mode 100644 index 0000000..91a32e9 --- /dev/null +++ b/config/pay.php @@ -0,0 +1,6 @@ + env('PAY_API_URL', 'https://ladill.com/api/pay'), + 'api_key' => env('PAY_API_KEY_EVENTS'), +]; diff --git a/database/migrations/2026_06_08_130000_add_pay_order_id_to_qr_event_registrations_table.php b/database/migrations/2026_06_08_130000_add_pay_order_id_to_qr_event_registrations_table.php new file mode 100644 index 0000000..40e3885 --- /dev/null +++ b/database/migrations/2026_06_08_130000_add_pay_order_id_to_qr_event_registrations_table.php @@ -0,0 +1,22 @@ +unsignedBigInteger('pay_order_id')->nullable()->after('id'); + }); + } + + public function down(): void + { + Schema::table('qr_event_registrations', function (Blueprint $table) { + $table->dropColumn('pay_order_id'); + }); + } +};