Deploy Ladill Events / deploy (push) Successful in 27s
External /q requests 301/307 to ladl.link; internal X-Ladill-Internal requests still serve content for platform proxying. Co-authored-by: Cursor <cursoragent@cursor.com>
291 lines
10 KiB
PHP
291 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Events;
|
|
|
|
use App\Models\QrCode;
|
|
use App\Models\QrEventRegistration;
|
|
use App\Services\Billing\BillingClient;
|
|
use App\Services\Billing\PaystackService;
|
|
use App\Services\Billing\SmsService;
|
|
use App\Services\Pay\PayClient;
|
|
use App\Support\LadillLink;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
|
|
class EventRegistrationService
|
|
{
|
|
public function __construct(
|
|
private PayClient $pay,
|
|
private PaystackService $paystack,
|
|
private BillingClient $billing,
|
|
private SmsService $sms,
|
|
) {}
|
|
|
|
/**
|
|
* Register an attendee. Free tiers confirm instantly; paid tiers return a
|
|
* Paystack checkout URL and stay pending until payment completes.
|
|
*
|
|
* @param array<string, mixed> $data
|
|
* @return array{registration: QrEventRegistration, paid: bool, checkout_url: ?string}
|
|
*/
|
|
public function register(QrCode $qrCode, array $data): array
|
|
{
|
|
$content = $qrCode->content();
|
|
|
|
$mode = ($content['mode'] ?? 'ticketing') === 'contributions' ? 'contributions' : 'ticketing';
|
|
|
|
if (empty($content['registration_open'])) {
|
|
throw new RuntimeException($mode === 'contributions'
|
|
? 'Contributions for this event are closed.'
|
|
: 'Registration for this event is closed.');
|
|
}
|
|
|
|
$tierName = trim((string) ($data['tier'] ?? ''));
|
|
$tier = null;
|
|
if ($mode === 'contributions') {
|
|
if (! in_array($tierName, $content['contribution_categories'] ?? [], true)) {
|
|
throw new RuntimeException('Select a valid contribution category.');
|
|
}
|
|
} else {
|
|
$tier = collect($content['tiers'] ?? [])->firstWhere('name', $tierName);
|
|
if (! $tier) {
|
|
throw new RuntimeException('Select a valid ticket type.');
|
|
}
|
|
}
|
|
|
|
$name = trim((string) ($data['attendee_name'] ?? ''));
|
|
if ($name === '') {
|
|
throw new RuntimeException('Enter your full name.');
|
|
}
|
|
$email = trim((string) ($data['attendee_email'] ?? ''));
|
|
if ($email === '' || ! str_contains($email, '@')) {
|
|
throw new RuntimeException('Enter a valid email address.');
|
|
}
|
|
|
|
if ($mode === 'ticketing') {
|
|
$capacity = (int) ($tier['capacity'] ?? 0);
|
|
if ($capacity > 0) {
|
|
$taken = QrEventRegistration::query()
|
|
->where('qr_code_id', $qrCode->id)
|
|
->where('tier_name', $tierName)
|
|
->where('status', QrEventRegistration::STATUS_CONFIRMED)
|
|
->count();
|
|
if ($taken >= $capacity) {
|
|
throw new RuntimeException('Sorry, "'.$tierName.'" is sold out.');
|
|
}
|
|
}
|
|
}
|
|
|
|
$badgeFields = [];
|
|
foreach ($content['badge_fields'] ?? [] as $label) {
|
|
$value = trim((string) (($data['badge_fields'] ?? [])[$label] ?? ''));
|
|
if ($value !== '') {
|
|
$badgeFields[$label] = mb_substr($value, 0, 120);
|
|
}
|
|
}
|
|
|
|
if ($mode === 'contributions') {
|
|
$priceGhs = round((float) ($data['amount'] ?? 0), 2);
|
|
if ($priceGhs <= 0) {
|
|
throw new RuntimeException('Enter a contribution amount.');
|
|
}
|
|
} else {
|
|
$priceGhs = round((float) ($tier['price'] ?? 0), 2);
|
|
}
|
|
$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),
|
|
'attendee_email' => $email,
|
|
'attendee_phone' => trim((string) ($data['attendee_phone'] ?? '')) ?: null,
|
|
'badge_fields' => $badgeFields ?: null,
|
|
'status' => $amountMinor > 0 ? QrEventRegistration::STATUS_PENDING : QrEventRegistration::STATUS_CONFIRMED,
|
|
]);
|
|
|
|
if ($amountMinor === 0) {
|
|
$this->notifyConfirmed($registration->fresh('qrCode'));
|
|
|
|
return ['registration' => $registration, 'paid' => false, 'checkout_url' => null];
|
|
}
|
|
|
|
$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);
|
|
|
|
$payOrder = $this->pay->createCheckout([
|
|
'merchant' => $qrCode->user->public_id,
|
|
'fee_tier' => $feeTier,
|
|
'source_service' => 'events',
|
|
'source_ref' => (string) $qrCode->id,
|
|
'callback_url' => LadillLink::path($qrCode->short_code, 'register/callback'),
|
|
'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_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->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)
|
|
->firstOrFail();
|
|
|
|
$data = $this->paystack->verifyTransaction($reference);
|
|
|
|
if (($data['status'] ?? '') !== 'success') {
|
|
$registration->update(['status' => QrEventRegistration::STATUS_FAILED]);
|
|
throw new RuntimeException('Payment was not successful.');
|
|
}
|
|
|
|
$feeRate = ($registration->qrCode?->content()['mode'] ?? 'ticketing') === 'contributions' ? 0.035 : 0.055;
|
|
$paidAmount = round(($data['amount'] ?? 0) / 100, 2);
|
|
$platformFee = round($paidAmount * $feeRate, 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,
|
|
'legacy' => true,
|
|
]),
|
|
]);
|
|
|
|
$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)',
|
|
$mode === 'contributions' ? 'Event contribution' : 'Event ticket',
|
|
$registration->qrCode->label,
|
|
$registration->tier_name
|
|
),
|
|
);
|
|
|
|
$this->notifyConfirmed($registration->fresh('qrCode'));
|
|
|
|
return $registration;
|
|
}
|
|
|
|
private function notifyConfirmed(QrEventRegistration $registration): void
|
|
{
|
|
if (! $registration->attendee_phone) {
|
|
return;
|
|
}
|
|
|
|
$content = $registration->qrCode?->content() ?? [];
|
|
$eventName = $content['name'] ?? $registration->qrCode?->label ?? config('app.name');
|
|
$firstName = explode(' ', $registration->attendee_name)[0];
|
|
|
|
if (($content['mode'] ?? 'ticketing') === 'contributions') {
|
|
$message = sprintf(
|
|
'Hi %s, thank you for your %s of %s %s to %s. Ref: %s',
|
|
$firstName,
|
|
$registration->tier_name,
|
|
$registration->currency,
|
|
number_format($registration->amountCedis(), 2),
|
|
$eventName,
|
|
$registration->badge_code
|
|
);
|
|
} else {
|
|
$message = sprintf(
|
|
'Hi %s, you are registered for %s (%s). Badge code: %s',
|
|
$firstName,
|
|
$eventName,
|
|
$registration->tier_name,
|
|
$registration->badge_code
|
|
);
|
|
}
|
|
|
|
$this->sms->send($registration->attendee_phone, $message);
|
|
}
|
|
|
|
private function uniqueBadgeCode(): string
|
|
{
|
|
do {
|
|
$code = strtoupper(Str::random(8));
|
|
} while (QrEventRegistration::where('badge_code', $code)->exists());
|
|
|
|
return $code;
|
|
}
|
|
}
|