Add Events Pro/Business and BYO ticket gateways.
Deploy Ladill Events / deploy (push) Successful in 42s
Deploy Ladill Events / deploy (push) Successful in 42s
Cut ticket checkouts off Ladill Pay, settle to merchant gateways at 0% platform fee, and mirror Invoice freemium pricing (GHS 49 / 149). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Events\ProSubscription;
|
||||
use App\Services\Events\SubscriptionService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RenewProSubscriptionsCommand extends Command
|
||||
{
|
||||
protected $signature = 'events:pro-renew';
|
||||
|
||||
protected $description = 'Charge the Ladill wallet for due Events Pro subscriptions.';
|
||||
|
||||
public function handle(SubscriptionService $subscriptions): int
|
||||
{
|
||||
$due = ProSubscription::where('status', ProSubscription::STATUS_ACTIVE)
|
||||
->where('auto_renew', true)
|
||||
->where('current_period_end', '<=', now())
|
||||
->get();
|
||||
|
||||
$this->info("Renewing {$due->count()} due subscription(s).");
|
||||
$due->each(fn (ProSubscription $sub) => $subscriptions->renewIfDue($sub));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Events\SubscriptionService;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -14,7 +15,10 @@ use Throwable;
|
||||
|
||||
class OverviewController extends Controller
|
||||
{
|
||||
public function __construct(private BillingClient $billing) {}
|
||||
public function __construct(
|
||||
private BillingClient $billing,
|
||||
private SubscriptionService $subscriptions,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
@@ -83,6 +87,7 @@ class OverviewController extends Controller
|
||||
'recentEvents' => $recentEvents,
|
||||
'programmeCount' => $programmeCount,
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'hasPaidPlan' => $this->subscriptions->hasPaidPlan($account),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Events;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Events\ProSubscription;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Events\SubscriptionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProController extends Controller
|
||||
{
|
||||
public function __construct(private readonly SubscriptionService $subscriptions) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$user = ladill_account() ?? $request->user();
|
||||
$planKey = $this->subscriptions->planKey($user);
|
||||
|
||||
return view('events.pro.index', [
|
||||
'subscription' => $this->subscriptions->subscriptionFor($user),
|
||||
'planKey' => $planKey,
|
||||
'isPro' => $planKey === ProSubscription::PLAN_PRO,
|
||||
'isEnterprise' => $planKey === ProSubscription::PLAN_ENTERPRISE,
|
||||
'hasPaidPlan' => $this->subscriptions->hasPaidPlan($user),
|
||||
'gatingActive' => $this->subscriptions->gatingActive(),
|
||||
'proPriceMinor' => $this->subscriptions->priceMinor(),
|
||||
'enterprisePriceMinor' => $this->subscriptions->enterprisePriceMinor(),
|
||||
'prepaidMonths' => (array) config('events.prepaid_months', [6, 12, 24]),
|
||||
'currency' => (string) config('events.pro.currency', 'GHS'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function subscribe(Request $request): RedirectResponse
|
||||
{
|
||||
[$ok, $message] = $this->subscriptions->subscribe(ladill_account() ?? $request->user());
|
||||
|
||||
return redirect()->route('events.pro.index')->with($ok ? 'success' : 'error', $message);
|
||||
}
|
||||
|
||||
public function subscribeEnterprise(Request $request): RedirectResponse
|
||||
{
|
||||
[$ok, $message] = $this->subscriptions->subscribeEnterprise(ladill_account() ?? $request->user());
|
||||
|
||||
return redirect()->route('events.pro.index')->with($ok ? 'success' : 'error', $message);
|
||||
}
|
||||
|
||||
public function subscribePrepaid(Request $request, BillingClient $billing): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'plan' => ['required', 'in:pro,enterprise'],
|
||||
'months' => ['required', 'integer', 'in:6,12,24'],
|
||||
]);
|
||||
|
||||
$user = ladill_account() ?? $request->user();
|
||||
if ($this->subscriptions->hasPaidPlan($user)) {
|
||||
return redirect()->route('events.pro.index')
|
||||
->with('success', 'You already have an active subscription.');
|
||||
}
|
||||
|
||||
$plan = $validated['plan'];
|
||||
$months = (int) $validated['months'];
|
||||
$monthlyMinor = $plan === 'enterprise'
|
||||
? $this->subscriptions->enterprisePriceMinor()
|
||||
: $this->subscriptions->priceMinor();
|
||||
|
||||
try {
|
||||
$checkout = $billing->initiatePlanCheckout(
|
||||
$user,
|
||||
$plan,
|
||||
$months,
|
||||
$monthlyMinor * $months,
|
||||
route('events.pro.paystack.callback'),
|
||||
['user_id' => $user->id],
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
return redirect()->route('events.pro.index')
|
||||
->with('error', 'Could not start Paystack checkout. Please try again.');
|
||||
}
|
||||
|
||||
$url = trim((string) ($checkout['checkout_url'] ?? ''));
|
||||
if ($url === '') {
|
||||
return redirect()->route('events.pro.index')
|
||||
->with('error', 'Paystack did not return a checkout URL.');
|
||||
}
|
||||
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
public function paystackCallback(Request $request, BillingClient $billing): RedirectResponse
|
||||
{
|
||||
$reference = (string) $request->query('reference', '');
|
||||
if ($reference === '') {
|
||||
return redirect()->route('events.pro.index')->with('error', 'Missing payment reference.');
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $billing->verifyPlanCheckout($reference);
|
||||
} catch (\Throwable) {
|
||||
return redirect()->route('events.pro.index')
|
||||
->with('error', 'Could not verify payment. Contact support with reference: '.$reference);
|
||||
}
|
||||
|
||||
if (! ($result['paid'] ?? false)) {
|
||||
return redirect()->route('events.pro.index')
|
||||
->with('error', 'Payment was not completed.');
|
||||
}
|
||||
|
||||
$metadata = (array) ($result['metadata'] ?? []);
|
||||
$user = User::query()->find((int) ($metadata['user_id'] ?? 0));
|
||||
$account = ladill_account() ?? $request->user();
|
||||
if (! $user || ! $account || $user->id !== $account->id) {
|
||||
return redirect()->route('events.pro.index')
|
||||
->with('error', 'Account not found for this payment.');
|
||||
}
|
||||
|
||||
$plan = (string) ($result['plan'] ?? 'pro');
|
||||
$months = (int) ($result['months'] ?? 0);
|
||||
$this->subscriptions->activatePrepaid($user, $plan, $months);
|
||||
|
||||
$label = $plan === 'enterprise' ? 'Events Business' : 'Events Pro';
|
||||
|
||||
return redirect()->route('events.pro.index')
|
||||
->with('success', "{$label} is active for {$months} months.");
|
||||
}
|
||||
|
||||
public function cancel(Request $request): RedirectResponse
|
||||
{
|
||||
[$ok, $message] = $this->subscriptions->cancel(ladill_account() ?? $request->user());
|
||||
|
||||
return redirect()->route('events.pro.index')->with($ok ? 'success' : 'error', $message);
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,25 @@
|
||||
namespace App\Http\Controllers\Qr;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PaymentGatewaySetting;
|
||||
use App\Models\QrSetting;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Payments\MerchantGatewayService;
|
||||
use App\Support\AccountBranding;
|
||||
use App\Support\Qr\QrCornerStyleCatalog;
|
||||
use App\Support\Qr\QrFrameStyleCatalog;
|
||||
use App\Support\Qr\QrModuleStyleCatalog;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AccountController extends Controller
|
||||
{
|
||||
public function __construct(private BillingClient $billing) {}
|
||||
public function __construct(
|
||||
private BillingClient $billing,
|
||||
private MerchantGatewayService $gateway,
|
||||
) {}
|
||||
|
||||
private function topupUrl(): string
|
||||
{
|
||||
@@ -76,6 +82,7 @@ class AccountController extends Controller
|
||||
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
|
||||
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
|
||||
'frameStyles' => QrFrameStyleCatalog::visible(),
|
||||
'gateway' => $this->gateway->settingFor($account),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -115,6 +122,16 @@ class AccountController extends Controller
|
||||
'default_style.gradient_rotation' => ['nullable', 'integer', 'min:0', 'max:360'],
|
||||
'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
|
||||
'remove_logo' => ['nullable', 'boolean'],
|
||||
'gateway_provider' => ['nullable', Rule::in([
|
||||
PaymentGatewaySetting::PROVIDER_PAYSTACK,
|
||||
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
|
||||
PaymentGatewaySetting::PROVIDER_HUBTEL,
|
||||
'',
|
||||
])],
|
||||
'gateway_public_key' => ['nullable', 'string', 'max:2000'],
|
||||
'gateway_secret_key' => ['nullable', 'string', 'max:2000'],
|
||||
'gateway_webhook_secret' => ['nullable', 'string', 'max:2000'],
|
||||
'gateway_is_active' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$eventDefaults = $data['event_defaults'] ?? [];
|
||||
@@ -146,6 +163,27 @@ class AccountController extends Controller
|
||||
],
|
||||
);
|
||||
|
||||
$provider = (string) ($data['gateway_provider'] ?? '');
|
||||
if ($provider !== '') {
|
||||
$existing = PaymentGatewaySetting::query()->firstOrNew([
|
||||
'owner_ref' => $account->public_id,
|
||||
]);
|
||||
$existing->provider = $provider;
|
||||
$existing->is_active = $request->boolean('gateway_is_active', true);
|
||||
if (filled($data['gateway_public_key'] ?? null)) {
|
||||
$existing->public_key = $data['gateway_public_key'];
|
||||
}
|
||||
if (filled($data['gateway_secret_key'] ?? null)) {
|
||||
$existing->secret_key = $data['gateway_secret_key'];
|
||||
}
|
||||
if (array_key_exists('gateway_webhook_secret', $data) && $data['gateway_webhook_secret'] !== null) {
|
||||
$existing->webhook_secret = $data['gateway_webhook_secret'] !== ''
|
||||
? $data['gateway_webhook_secret']
|
||||
: null;
|
||||
}
|
||||
$existing->save();
|
||||
}
|
||||
|
||||
return redirect()->route('account.settings')->with('success', 'Settings saved.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Models\QrWallet;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Events\SubscriptionService;
|
||||
use App\Services\Qr\QrAnalyticsService;
|
||||
use App\Services\Qr\QrCodeManagerService;
|
||||
use App\Services\Qr\QrImageGeneratorService;
|
||||
@@ -37,6 +38,7 @@ class QrCodeController extends Controller
|
||||
private QrImageGeneratorService $imageGenerator,
|
||||
private QrPdfExporter $pdfExporter,
|
||||
private BillingClient $platformBilling,
|
||||
private SubscriptionService $subscriptions,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
@@ -72,8 +74,15 @@ class QrCodeController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
public function create(Request $request): View|RedirectResponse
|
||||
{
|
||||
$account = ladill_account() ?? $request->user();
|
||||
$requestedType = $request->query('type', QrCode::TYPE_EVENT);
|
||||
if ($account && ($requestedType === QrCode::TYPE_EVENT) && ! $this->subscriptions->canCreateEvent($account)) {
|
||||
return redirect()->route('events.pro.index')
|
||||
->with('upsell', 'Free plan includes up to '.config('events.free.max_live_events', 2).' live events. Upgrade for unlimited.');
|
||||
}
|
||||
|
||||
$account = ladill_account();
|
||||
$wallet = $this->manager->walletFor($account);
|
||||
$qrSettings = $account->getOrCreateQrSetting();
|
||||
@@ -126,6 +135,13 @@ class QrCodeController extends Controller
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account() ?? $request->user();
|
||||
$requestedType = (string) $request->input('type', QrCode::TYPE_EVENT);
|
||||
if ($account && $requestedType === QrCode::TYPE_EVENT && ! $this->subscriptions->canCreateEvent($account)) {
|
||||
return redirect()->route('events.pro.index')
|
||||
->with('upsell', 'Free plan includes up to '.config('events.free.max_live_events', 2).' live events. Upgrade for unlimited.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'label' => ['required', 'string', 'max:120'],
|
||||
'type' => ['required', 'in:' . implode(',', QrTypeCatalog::keys())],
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Events\SubscriptionService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsurePro
|
||||
{
|
||||
public function __construct(private readonly SubscriptionService $subscriptions) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = ladill_account() ?? $request->user();
|
||||
if ($user && $this->subscriptions->isPro($user)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return redirect()->route('events.pro.index')
|
||||
->with('upsell', 'This feature is part of Ladill Events Pro or Business.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Events;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProSubscription extends Model
|
||||
{
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_CANCELED = 'canceled';
|
||||
|
||||
public const STATUS_PAST_DUE = 'past_due';
|
||||
|
||||
public const PLAN_PRO = 'pro';
|
||||
|
||||
public const PLAN_ENTERPRISE = 'enterprise';
|
||||
|
||||
protected $table = 'events_pro_subscriptions';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'status', 'plan', 'price_minor', 'currency', 'auto_renew',
|
||||
'started_at', 'current_period_end', 'last_charged_at', 'canceled_at',
|
||||
'last_reference', 'last_error',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'auto_renew' => 'boolean',
|
||||
'price_minor' => 'integer',
|
||||
'started_at' => 'datetime',
|
||||
'current_period_end' => 'datetime',
|
||||
'last_charged_at' => 'datetime',
|
||||
'canceled_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function entitled(): bool
|
||||
{
|
||||
return $this->status !== self::STATUS_PAST_DUE
|
||||
&& $this->current_period_end !== null
|
||||
&& $this->current_period_end->isFuture();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PaymentGatewaySetting extends Model
|
||||
{
|
||||
public const PROVIDER_PAYSTACK = 'paystack';
|
||||
|
||||
public const PROVIDER_FLUTTERWAVE = 'flutterwave';
|
||||
|
||||
public const PROVIDER_HUBTEL = 'hubtel';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref',
|
||||
'provider',
|
||||
'public_key',
|
||||
'secret_key',
|
||||
'webhook_secret',
|
||||
'is_active',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'public_key' => 'encrypted',
|
||||
'secret_key' => 'encrypted',
|
||||
'webhook_secret' => 'encrypted',
|
||||
'is_active' => 'boolean',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
if (! $this->is_active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$secret = trim((string) $this->secret_key);
|
||||
|
||||
return $secret !== '' && in_array($this->provider, [
|
||||
self::PROVIDER_PAYSTACK,
|
||||
self::PROVIDER_FLUTTERWAVE,
|
||||
self::PROVIDER_HUBTEL,
|
||||
], true);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Client for the platform Billing HTTP API — the one UserWallet lives on the
|
||||
@@ -84,4 +86,94 @@ class BillingClient
|
||||
|
||||
return (array) $res->json();
|
||||
}
|
||||
|
||||
public function configured(): bool
|
||||
{
|
||||
return $this->base() !== '' && $this->token() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, insufficient: bool, balance_minor: ?int, error: ?string}
|
||||
*/
|
||||
public function charge(User $user, int $amountMinor, string $reference, string $description): array
|
||||
{
|
||||
if (! $this->configured()) {
|
||||
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Billing is not configured.'];
|
||||
}
|
||||
|
||||
try {
|
||||
$res = Http::withToken($this->token())
|
||||
->acceptJson()->timeout(20)
|
||||
->post($this->base().'/debit', [
|
||||
'user' => $user->public_id,
|
||||
'amount_minor' => $amountMinor,
|
||||
'service' => (string) config('billing.service', 'events'),
|
||||
'source' => 'subscription',
|
||||
'reference' => $reference,
|
||||
'description' => $description,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Events BillingClient: charge failed', ['user' => $user->public_id, 'error' => $e->getMessage()]);
|
||||
|
||||
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Could not reach the billing service.'];
|
||||
}
|
||||
|
||||
if ($res->status() === 402) {
|
||||
return ['ok' => false, 'insufficient' => true, 'balance_minor' => (int) $res->json('balance_minor', 0), 'error' => 'Insufficient wallet balance.'];
|
||||
}
|
||||
|
||||
if ($res->failed()) {
|
||||
return ['ok' => false, 'insufficient' => false, 'balance_minor' => null, 'error' => 'Billing error ('.$res->status().').'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'insufficient' => false, 'balance_minor' => null, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
* @return array{checkout_url: string, reference: string}
|
||||
*/
|
||||
public function initiatePlanCheckout(
|
||||
User $user,
|
||||
string $plan,
|
||||
int $months,
|
||||
int $amountMinor,
|
||||
string $returnUrl,
|
||||
array $metadata = [],
|
||||
): array {
|
||||
$res = Http::withToken($this->token())
|
||||
->acceptJson()->timeout(15)
|
||||
->post($this->base().'/plan-checkout', [
|
||||
'user' => $user->public_id,
|
||||
'app' => (string) config('billing.service', 'events'),
|
||||
'plan' => $plan,
|
||||
'months' => $months,
|
||||
'amount_minor' => $amountMinor,
|
||||
'return_url' => $returnUrl,
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
$res->throw();
|
||||
|
||||
return [
|
||||
'checkout_url' => (string) $res->json('checkout_url'),
|
||||
'reference' => (string) $res->json('reference'),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function verifyPlanCheckout(string $reference): array
|
||||
{
|
||||
$res = Http::withToken($this->token())
|
||||
->acceptJson()->timeout(15)
|
||||
->post($this->base().'/plan-checkout/verify', [
|
||||
'reference' => $reference,
|
||||
]);
|
||||
|
||||
if ($res->status() === 422) {
|
||||
return ['paid' => false, 'error' => $res->json('error')];
|
||||
}
|
||||
$res->throw();
|
||||
|
||||
return $res->json();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,10 @@ 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\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;
|
||||
use RuntimeException;
|
||||
@@ -17,9 +15,8 @@ use RuntimeException;
|
||||
class EventRegistrationService
|
||||
{
|
||||
public function __construct(
|
||||
private PayClient $pay,
|
||||
private PaystackService $paystack,
|
||||
private BillingClient $billing,
|
||||
private MerchantGatewayService $gateway,
|
||||
private SubscriptionService $subscriptions,
|
||||
private SmsService $sms,
|
||||
private EventEmailService $email,
|
||||
private EventMeetAccessService $meetAccess,
|
||||
@@ -27,7 +24,7 @@ class EventRegistrationService
|
||||
|
||||
/**
|
||||
* Register an attendee. Free tiers confirm instantly; paid tiers return a
|
||||
* Paystack checkout URL and stay pending until payment completes.
|
||||
* merchant-gateway checkout URL and stay pending until payment completes.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return array{registration: QrEventRegistration, paid: bool, checkout_url: ?string}
|
||||
@@ -98,7 +95,12 @@ class EventRegistrationService
|
||||
}
|
||||
$amountMinor = (int) round($priceGhs * 100);
|
||||
|
||||
$meetReturn = trim((string) ($data['meet_return'] ?? ''));
|
||||
$qrCode->loadMissing('user');
|
||||
if ($qrCode->user && ! $this->subscriptions->canAcceptTicket($qrCode->user)) {
|
||||
throw new RuntimeException('This organizer has reached the free monthly ticket limit. Ask them to upgrade Events Pro.');
|
||||
}
|
||||
|
||||
$meetReturn = trim((string) ($data['meet_return'] ?? ''));
|
||||
$metadata = $meetReturn !== '' ? ['meet_return' => $meetReturn] : null;
|
||||
|
||||
$registration = QrEventRegistration::create([
|
||||
@@ -124,131 +126,65 @@ class EventRegistrationService
|
||||
}
|
||||
|
||||
$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' => [
|
||||
$checkout = $this->gateway->initializeCheckout(
|
||||
$qrCode->user,
|
||||
$amountMinor,
|
||||
(string) ($registration->currency ?? 'GHS'),
|
||||
$email,
|
||||
LadillLink::path($qrCode->short_code, 'register/callback'),
|
||||
$registration->reference,
|
||||
[
|
||||
'title' => $lineName,
|
||||
'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'],
|
||||
'payment_reference' => $checkout['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,
|
||||
'checkout_url' => $checkout['checkout_url'],
|
||||
];
|
||||
}
|
||||
|
||||
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)
|
||||
->with('qrCode.user')
|
||||
->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') {
|
||||
$owner = $registration->qrCode?->user ?? $registration->organizer;
|
||||
$result = $this->gateway->verify($owner, $reference);
|
||||
if (! $result['paid']) {
|
||||
$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(),
|
||||
'amount_minor' => (int) ($result['amount_minor'] ?: $registration->amount_minor),
|
||||
'metadata' => array_merge((array) $registration->metadata, [
|
||||
'paystack' => $data,
|
||||
'platform_fee_ghs' => $platformFee,
|
||||
'legacy' => true,
|
||||
'merchant_gateway' => [
|
||||
'provider' => $result['provider'],
|
||||
'reference' => $result['reference'],
|
||||
'raw' => $result['raw'],
|
||||
],
|
||||
'platform_fee_ghs' => 0,
|
||||
]),
|
||||
]);
|
||||
|
||||
$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;
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
use App\Models\Events\ProSubscription;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SubscriptionService
|
||||
{
|
||||
public function __construct(private readonly BillingClient $billing) {}
|
||||
|
||||
public function gatingActive(): bool
|
||||
{
|
||||
return (bool) config('events.pro.enabled', true) && $this->billing->configured();
|
||||
}
|
||||
|
||||
public function priceMinor(): int
|
||||
{
|
||||
return (int) config('events.plans.pro.price_minor', config('events.pro.price_minor', 4900));
|
||||
}
|
||||
|
||||
public function enterprisePriceMinor(): int
|
||||
{
|
||||
return (int) config('events.plans.enterprise.price_minor', 14900);
|
||||
}
|
||||
|
||||
public function subscriptionFor(User $user): ?ProSubscription
|
||||
{
|
||||
return ProSubscription::where('user_id', $user->id)->first();
|
||||
}
|
||||
|
||||
public function planKey(User $user): string
|
||||
{
|
||||
if (! $this->gatingActive()) {
|
||||
return ProSubscription::PLAN_PRO;
|
||||
}
|
||||
|
||||
$sub = $this->subscriptionFor($user);
|
||||
if (! $sub || ! $sub->entitled()) {
|
||||
return 'free';
|
||||
}
|
||||
|
||||
return $sub->plan === ProSubscription::PLAN_ENTERPRISE
|
||||
? ProSubscription::PLAN_ENTERPRISE
|
||||
: ProSubscription::PLAN_PRO;
|
||||
}
|
||||
|
||||
public function hasPaidPlan(User $user): bool
|
||||
{
|
||||
if (! $this->gatingActive()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) $this->subscriptionFor($user)?->entitled();
|
||||
}
|
||||
|
||||
public function isEnterprise(User $user): bool
|
||||
{
|
||||
return $this->planKey($user) === ProSubscription::PLAN_ENTERPRISE;
|
||||
}
|
||||
|
||||
public function isPro(User $user): bool
|
||||
{
|
||||
return $this->hasPaidPlan($user);
|
||||
}
|
||||
|
||||
public function liveEventCount(User $user): int
|
||||
{
|
||||
return QrCode::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('type', QrCode::TYPE_EVENT)
|
||||
->count();
|
||||
}
|
||||
|
||||
public function canCreateEvent(User $user): bool
|
||||
{
|
||||
if ($this->isPro($user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->liveEventCount($user) < (int) config('events.free.max_live_events', 2);
|
||||
}
|
||||
|
||||
public function ticketsThisMonth(User $user): int
|
||||
{
|
||||
return QrEventRegistration::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('status', QrEventRegistration::STATUS_CONFIRMED)
|
||||
->where('created_at', '>=', now()->startOfMonth())
|
||||
->count();
|
||||
}
|
||||
|
||||
public function canAcceptTicket(User $user): bool
|
||||
{
|
||||
if ($this->isPro($user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->ticketsThisMonth($user) < (int) config('events.free.max_tickets_per_month', 100);
|
||||
}
|
||||
|
||||
/** @return array{0:bool,1:string} */
|
||||
public function subscribe(User $user): array
|
||||
{
|
||||
$existing = $this->subscriptionFor($user);
|
||||
if ($existing && $existing->entitled() && $existing->status === ProSubscription::STATUS_ACTIVE) {
|
||||
return [true, 'You already have an active subscription.'];
|
||||
}
|
||||
|
||||
if ($existing?->plan === ProSubscription::PLAN_ENTERPRISE) {
|
||||
return $this->subscribeEnterprise($user);
|
||||
}
|
||||
|
||||
return $this->activateWalletPlan($user, ProSubscription::PLAN_PRO, $this->priceMinor(), 'Ladill Events Pro — monthly subscription', 'Welcome to Ladill Events Pro!');
|
||||
}
|
||||
|
||||
/** @return array{0:bool,1:string} */
|
||||
public function subscribeEnterprise(User $user): array
|
||||
{
|
||||
$existing = $this->subscriptionFor($user);
|
||||
if ($existing && $existing->entitled() && $existing->status === ProSubscription::STATUS_ACTIVE) {
|
||||
return [true, 'You already have an active subscription.'];
|
||||
}
|
||||
|
||||
return $this->activateWalletPlan(
|
||||
$user,
|
||||
ProSubscription::PLAN_ENTERPRISE,
|
||||
$this->enterprisePriceMinor(),
|
||||
'Ladill Events Business — monthly subscription',
|
||||
'Welcome to Ladill Events Business!',
|
||||
);
|
||||
}
|
||||
|
||||
public function activatePrepaid(User $user, string $plan, int $months): void
|
||||
{
|
||||
$existing = $this->subscriptionFor($user);
|
||||
$price = $plan === ProSubscription::PLAN_ENTERPRISE
|
||||
? $this->enterprisePriceMinor()
|
||||
: $this->priceMinor();
|
||||
|
||||
ProSubscription::updateOrCreate(
|
||||
['user_id' => $user->id],
|
||||
[
|
||||
'plan' => $plan,
|
||||
'status' => ProSubscription::STATUS_ACTIVE,
|
||||
'price_minor' => $price,
|
||||
'currency' => (string) config('events.pro.currency', 'GHS'),
|
||||
'auto_renew' => false,
|
||||
'started_at' => $existing?->started_at ?? now(),
|
||||
'current_period_end' => Carbon::now()->addMonths($months),
|
||||
'last_charged_at' => now(),
|
||||
'canceled_at' => null,
|
||||
'last_reference' => null,
|
||||
'last_error' => null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{0:bool,1:string} */
|
||||
public function cancel(User $user): array
|
||||
{
|
||||
$sub = $this->subscriptionFor($user);
|
||||
if (! $sub || ! $sub->entitled()) {
|
||||
return [false, 'You do not have an active subscription.'];
|
||||
}
|
||||
|
||||
$sub->forceFill([
|
||||
'status' => ProSubscription::STATUS_CANCELED,
|
||||
'auto_renew' => false,
|
||||
'canceled_at' => now(),
|
||||
])->save();
|
||||
|
||||
$until = optional($sub->current_period_end)->format('d M Y');
|
||||
|
||||
return [true, "Auto-renew is off. You keep access until {$until}."];
|
||||
}
|
||||
|
||||
public function renewIfDue(ProSubscription $sub): void
|
||||
{
|
||||
if (! $sub->auto_renew || $sub->status !== ProSubscription::STATUS_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
if ($sub->current_period_end && $sub->current_period_end->isFuture()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $sub->user;
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$planLabel = $sub->plan === ProSubscription::PLAN_ENTERPRISE ? 'Business' : 'Pro';
|
||||
$reference = 'events-'.$sub->plan.'-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6);
|
||||
$result = $this->billing->charge($user, $sub->price_minor, $reference, "Ladill Events {$planLabel} — renewal");
|
||||
|
||||
if ($result['ok']) {
|
||||
$sub->forceFill([
|
||||
'current_period_end' => Carbon::now()->addDays((int) config('events.pro.period_days', 30)),
|
||||
'last_charged_at' => now(),
|
||||
'last_reference' => $reference,
|
||||
'last_error' => null,
|
||||
])->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$graceEnd = optional($sub->current_period_end)->addDays((int) config('events.pro.grace_days', 3));
|
||||
if ($graceEnd && $graceEnd->isPast()) {
|
||||
$sub->forceFill(['status' => ProSubscription::STATUS_PAST_DUE, 'last_error' => $result['error']])->save();
|
||||
} else {
|
||||
$sub->forceFill(['last_error' => $result['error']])->save();
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{0:bool,1:string} */
|
||||
private function activateWalletPlan(User $user, string $plan, int $price, string $description, string $successMessage): array
|
||||
{
|
||||
$existing = $this->subscriptionFor($user);
|
||||
$reference = 'events-'.$plan.'-'.$user->id.'-'.now()->format('YmdHis').'-'.Str::random(6);
|
||||
$result = $this->billing->charge($user, $price, $reference, $description);
|
||||
|
||||
if (! $result['ok']) {
|
||||
if ($result['insufficient']) {
|
||||
$bal = number_format(((int) $result['balance_minor']) / 100, 2);
|
||||
|
||||
return [false, "Your wallet balance (GHS {$bal}) is too low. Top up, then subscribe."];
|
||||
}
|
||||
|
||||
return [false, $result['error'] ?? 'Could not start your subscription. Please try again.'];
|
||||
}
|
||||
|
||||
$periodEnd = Carbon::now()->addDays((int) config('events.pro.period_days', 30));
|
||||
ProSubscription::updateOrCreate(
|
||||
['user_id' => $user->id],
|
||||
[
|
||||
'plan' => $plan,
|
||||
'status' => ProSubscription::STATUS_ACTIVE,
|
||||
'price_minor' => $price,
|
||||
'currency' => (string) config('events.pro.currency', 'GHS'),
|
||||
'auto_renew' => true,
|
||||
'started_at' => $existing?->started_at ?? now(),
|
||||
'current_period_end' => $periodEnd,
|
||||
'last_charged_at' => now(),
|
||||
'canceled_at' => null,
|
||||
'last_reference' => $reference,
|
||||
'last_error' => null,
|
||||
],
|
||||
);
|
||||
|
||||
return [true, $successMessage.' Your subscription is active.'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\PaymentGatewaySetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class MerchantGatewayService
|
||||
{
|
||||
public function settingFor(User|string $owner): ?PaymentGatewaySetting
|
||||
{
|
||||
$ownerRef = $owner instanceof User ? (string) $owner->public_id : $owner;
|
||||
|
||||
return PaymentGatewaySetting::query()->where('owner_ref', $ownerRef)->first();
|
||||
}
|
||||
|
||||
public function isConfigured(User|string $owner): bool
|
||||
{
|
||||
return (bool) $this->settingFor($owner)?->isConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
* @return array{checkout_url: string, reference: string, provider: string}
|
||||
*/
|
||||
public function initializeCheckout(
|
||||
User|string $owner,
|
||||
int $amountMinor,
|
||||
string $currency,
|
||||
string $email,
|
||||
string $callbackUrl,
|
||||
string $reference,
|
||||
array $metadata = [],
|
||||
): array {
|
||||
$setting = $this->requireConfigured($owner);
|
||||
$currency = strtoupper($currency ?: 'GHS');
|
||||
|
||||
return match ($setting->provider) {
|
||||
PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
|
||||
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE => $this->flutterwaveInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
|
||||
PaymentGatewaySetting::PROVIDER_HUBTEL => $this->hubtelInitialize($setting, $amountMinor, $currency, $email, $callbackUrl, $reference, $metadata),
|
||||
default => throw new RuntimeException('Unsupported payment provider.'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
|
||||
*/
|
||||
public function verify(User|string $owner, string $reference): array
|
||||
{
|
||||
$setting = $this->requireConfigured($owner);
|
||||
|
||||
return match ($setting->provider) {
|
||||
PaymentGatewaySetting::PROVIDER_PAYSTACK => $this->paystackVerify($setting, $reference),
|
||||
PaymentGatewaySetting::PROVIDER_FLUTTERWAVE => $this->flutterwaveVerify($setting, $reference),
|
||||
PaymentGatewaySetting::PROVIDER_HUBTEL => $this->hubtelVerify($setting, $reference),
|
||||
default => throw new RuntimeException('Unsupported payment provider.'),
|
||||
};
|
||||
}
|
||||
|
||||
protected function requireConfigured(User|string $owner): PaymentGatewaySetting
|
||||
{
|
||||
$setting = $this->settingFor($owner);
|
||||
if (! $setting?->isConfigured()) {
|
||||
throw new RuntimeException('Connect Paystack, Flutterwave, or Hubtel in Settings before accepting online payments.');
|
||||
}
|
||||
|
||||
return $setting;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
* @return array{checkout_url: string, reference: string, provider: string}
|
||||
*/
|
||||
protected function paystackInitialize(
|
||||
PaymentGatewaySetting $setting,
|
||||
int $amountMinor,
|
||||
string $currency,
|
||||
string $email,
|
||||
string $callbackUrl,
|
||||
string $reference,
|
||||
array $metadata,
|
||||
): array {
|
||||
$response = Http::withToken((string) $setting->secret_key)
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->post('https://api.paystack.co/transaction/initialize', [
|
||||
'email' => $email !== '' ? $email : 'payer@example.com',
|
||||
'amount' => $amountMinor,
|
||||
'currency' => $currency,
|
||||
'reference' => $reference,
|
||||
'callback_url' => $callbackUrl,
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
if (! $response->successful() || ! ($response->json('status') ?? false)) {
|
||||
throw new RuntimeException($response->json('message') ?: 'Paystack could not start checkout.');
|
||||
}
|
||||
|
||||
$url = (string) $response->json('data.authorization_url');
|
||||
if ($url === '') {
|
||||
throw new RuntimeException('Paystack did not return a checkout URL.');
|
||||
}
|
||||
|
||||
return [
|
||||
'checkout_url' => $url,
|
||||
'reference' => (string) ($response->json('data.reference') ?: $reference),
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
|
||||
*/
|
||||
protected function paystackVerify(PaymentGatewaySetting $setting, string $reference): array
|
||||
{
|
||||
$response = Http::withToken((string) $setting->secret_key)
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->get('https://api.paystack.co/transaction/verify/'.rawurlencode($reference));
|
||||
|
||||
$data = (array) ($response->json('data') ?? []);
|
||||
$paid = ($response->json('status') ?? false)
|
||||
&& strtolower((string) ($data['status'] ?? '')) === 'success';
|
||||
|
||||
return [
|
||||
'paid' => $paid,
|
||||
'amount_minor' => (int) ($data['amount'] ?? 0),
|
||||
'reference' => (string) ($data['reference'] ?? $reference),
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_PAYSTACK,
|
||||
'raw' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
* @return array{checkout_url: string, reference: string, provider: string}
|
||||
*/
|
||||
protected function flutterwaveInitialize(
|
||||
PaymentGatewaySetting $setting,
|
||||
int $amountMinor,
|
||||
string $currency,
|
||||
string $email,
|
||||
string $callbackUrl,
|
||||
string $reference,
|
||||
array $metadata,
|
||||
): array {
|
||||
$response = Http::withToken((string) $setting->secret_key)
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->post('https://api.flutterwave.com/v3/payments', [
|
||||
'tx_ref' => $reference,
|
||||
'amount' => round($amountMinor / 100, 2),
|
||||
'currency' => $currency,
|
||||
'redirect_url' => $callbackUrl,
|
||||
'customer' => [
|
||||
'email' => $email !== '' ? $email : 'payer@example.com',
|
||||
],
|
||||
'customizations' => [
|
||||
'title' => (string) ($metadata['title'] ?? 'Payment'),
|
||||
],
|
||||
'meta' => $metadata,
|
||||
]);
|
||||
|
||||
if (! $response->successful() || ($response->json('status') ?? '') !== 'success') {
|
||||
throw new RuntimeException($response->json('message') ?: 'Flutterwave could not start checkout.');
|
||||
}
|
||||
|
||||
$url = (string) $response->json('data.link');
|
||||
if ($url === '') {
|
||||
throw new RuntimeException('Flutterwave did not return a checkout URL.');
|
||||
}
|
||||
|
||||
return [
|
||||
'checkout_url' => $url,
|
||||
'reference' => $reference,
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
|
||||
*/
|
||||
protected function flutterwaveVerify(PaymentGatewaySetting $setting, string $reference): array
|
||||
{
|
||||
$response = Http::withToken((string) $setting->secret_key)
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->get('https://api.flutterwave.com/v3/transactions/verify_by_reference', [
|
||||
'tx_ref' => $reference,
|
||||
]);
|
||||
|
||||
$data = (array) ($response->json('data') ?? []);
|
||||
$paid = ($response->json('status') ?? '') === 'success'
|
||||
&& strtolower((string) ($data['status'] ?? '')) === 'successful';
|
||||
$amountMajor = (float) ($data['amount'] ?? 0);
|
||||
|
||||
return [
|
||||
'paid' => $paid,
|
||||
'amount_minor' => (int) round($amountMajor * 100),
|
||||
'reference' => (string) ($data['tx_ref'] ?? $reference),
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_FLUTTERWAVE,
|
||||
'raw' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
* @return array{checkout_url: string, reference: string, provider: string}
|
||||
*/
|
||||
protected function hubtelInitialize(
|
||||
PaymentGatewaySetting $setting,
|
||||
int $amountMinor,
|
||||
string $currency,
|
||||
string $email,
|
||||
string $callbackUrl,
|
||||
string $reference,
|
||||
array $metadata,
|
||||
): array {
|
||||
// Hubtel: public_key = merchant account number, secret_key = API key (client secret),
|
||||
// webhook_secret optionally stores client id for Basic auth as "clientId:clientSecret".
|
||||
$auth = trim((string) ($setting->webhook_secret ?: $setting->secret_key));
|
||||
if (! str_contains($auth, ':')) {
|
||||
$auth = trim((string) $setting->public_key).':'.trim((string) $setting->secret_key);
|
||||
}
|
||||
|
||||
$response = Http::withBasicAuth(...explode(':', $auth, 2))
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->post('https://payproxyapi.hubtel.com/items/initiate', [
|
||||
'totalAmount' => round($amountMinor / 100, 2),
|
||||
'description' => (string) ($metadata['title'] ?? 'Payment'),
|
||||
'callbackUrl' => $callbackUrl,
|
||||
'returnUrl' => $callbackUrl,
|
||||
'merchantAccountNumber' => (string) $setting->public_key,
|
||||
'cancellationUrl' => $callbackUrl,
|
||||
'clientReference' => $reference,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException($response->json('message') ?: 'Hubtel could not start checkout.');
|
||||
}
|
||||
|
||||
$url = (string) ($response->json('data.checkoutUrl') ?? $response->json('data.checkoutDirectUrl') ?? '');
|
||||
if ($url === '') {
|
||||
throw new RuntimeException('Hubtel did not return a checkout URL.');
|
||||
}
|
||||
|
||||
return [
|
||||
'checkout_url' => $url,
|
||||
'reference' => $reference,
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_HUBTEL,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{paid: bool, amount_minor: int, reference: string, provider: string, raw: array<string, mixed>}
|
||||
*/
|
||||
protected function hubtelVerify(PaymentGatewaySetting $setting, string $reference): array
|
||||
{
|
||||
$auth = trim((string) ($setting->webhook_secret ?: $setting->secret_key));
|
||||
if (! str_contains($auth, ':')) {
|
||||
$auth = trim((string) $setting->public_key).':'.trim((string) $setting->secret_key);
|
||||
}
|
||||
|
||||
$response = Http::withBasicAuth(...explode(':', $auth, 2))
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->get('https://api-txnstatus.hubtel.com/transactions/'.$setting->public_key.'/status', [
|
||||
'clientReference' => $reference,
|
||||
]);
|
||||
|
||||
$data = (array) ($response->json('data') ?? $response->json() ?? []);
|
||||
$status = strtolower((string) ($data['status'] ?? $data['transactionStatus'] ?? ''));
|
||||
$paid = in_array($status, ['success', 'successful', 'paid', 'completed'], true);
|
||||
$amountMajor = (float) ($data['amount'] ?? $data['totalAmount'] ?? 0);
|
||||
|
||||
return [
|
||||
'paid' => $paid,
|
||||
'amount_minor' => (int) round($amountMajor * 100),
|
||||
'reference' => $reference,
|
||||
'provider' => PaymentGatewaySetting::PROVIDER_HUBTEL,
|
||||
'raw' => $data,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user