From 4c87c786da165b5c53fbfe00fa9292d8e68fe7f6 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 15 Jul 2026 01:26:28 +0000 Subject: [PATCH] Add Events Pro/Business and BYO ticket gateways. 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 --- .../Commands/RenewProSubscriptionsCommand.php | 27 ++ .../Controllers/Events/OverviewController.php | 7 +- app/Http/Controllers/Events/ProController.php | 136 +++++++++ app/Http/Controllers/Qr/AccountController.php | 40 ++- app/Http/Controllers/Qr/QrCodeController.php | 18 +- app/Http/Middleware/EnsurePro.php | 24 ++ app/Models/Events/ProSubscription.php | 49 +++ app/Models/PaymentGatewaySetting.php | 50 +++ app/Services/Billing/BillingClient.php | 92 ++++++ .../Events/EventRegistrationService.php | 130 ++------ app/Services/Events/SubscriptionService.php | 257 ++++++++++++++++ .../Payments/MerchantGatewayService.php | 288 ++++++++++++++++++ config/events.php | 26 ++ config/ladill_launcher.php | 3 - ..._create_payment_gateway_settings_table.php | 28 ++ ..._create_events_pro_subscriptions_table.php | 35 +++ .../components/plan-tier-price.blade.php | 23 ++ resources/views/events/dashboard.blade.php | 1 + resources/views/events/payouts.blade.php | 4 +- resources/views/events/pro/index.blade.php | 100 ++++++ resources/views/partials/sidebar.blade.php | 5 + .../views/partials/upgrade-banner.blade.php | 10 + resources/views/qr/account/settings.blade.php | 40 +++ routes/console.php | 2 + routes/web.php | 8 + tests/Feature/EventsProTest.php | 78 +++++ 26 files changed, 1376 insertions(+), 105 deletions(-) create mode 100644 app/Console/Commands/RenewProSubscriptionsCommand.php create mode 100644 app/Http/Controllers/Events/ProController.php create mode 100644 app/Http/Middleware/EnsurePro.php create mode 100644 app/Models/Events/ProSubscription.php create mode 100644 app/Models/PaymentGatewaySetting.php create mode 100644 app/Services/Events/SubscriptionService.php create mode 100644 app/Services/Payments/MerchantGatewayService.php create mode 100644 database/migrations/2026_07_15_010000_create_payment_gateway_settings_table.php create mode 100644 database/migrations/2026_07_15_020000_create_events_pro_subscriptions_table.php create mode 100644 resources/views/components/plan-tier-price.blade.php create mode 100644 resources/views/events/pro/index.blade.php create mode 100644 resources/views/partials/upgrade-banner.blade.php create mode 100644 tests/Feature/EventsProTest.php diff --git a/app/Console/Commands/RenewProSubscriptionsCommand.php b/app/Console/Commands/RenewProSubscriptionsCommand.php new file mode 100644 index 0000000..a694e1c --- /dev/null +++ b/app/Console/Commands/RenewProSubscriptionsCommand.php @@ -0,0 +1,27 @@ +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; + } +} diff --git a/app/Http/Controllers/Events/OverviewController.php b/app/Http/Controllers/Events/OverviewController.php index 2dd014a..a796cda 100644 --- a/app/Http/Controllers/Events/OverviewController.php +++ b/app/Http/Controllers/Events/OverviewController.php @@ -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), ]); } } diff --git a/app/Http/Controllers/Events/ProController.php b/app/Http/Controllers/Events/ProController.php new file mode 100644 index 0000000..85086ad --- /dev/null +++ b/app/Http/Controllers/Events/ProController.php @@ -0,0 +1,136 @@ +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); + } +} diff --git a/app/Http/Controllers/Qr/AccountController.php b/app/Http/Controllers/Qr/AccountController.php index 04bb101..b71be3b 100644 --- a/app/Http/Controllers/Qr/AccountController.php +++ b/app/Http/Controllers/Qr/AccountController.php @@ -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.'); } } diff --git a/app/Http/Controllers/Qr/QrCodeController.php b/app/Http/Controllers/Qr/QrCodeController.php index 6dcee50..a6781a9 100644 --- a/app/Http/Controllers/Qr/QrCodeController.php +++ b/app/Http/Controllers/Qr/QrCodeController.php @@ -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())], diff --git a/app/Http/Middleware/EnsurePro.php b/app/Http/Middleware/EnsurePro.php new file mode 100644 index 0000000..1016a17 --- /dev/null +++ b/app/Http/Middleware/EnsurePro.php @@ -0,0 +1,24 @@ +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.'); + } +} diff --git a/app/Models/Events/ProSubscription.php b/app/Models/Events/ProSubscription.php new file mode 100644 index 0000000..4f896cb --- /dev/null +++ b/app/Models/Events/ProSubscription.php @@ -0,0 +1,49 @@ + '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(); + } +} diff --git a/app/Models/PaymentGatewaySetting.php b/app/Models/PaymentGatewaySetting.php new file mode 100644 index 0000000..7c34791 --- /dev/null +++ b/app/Models/PaymentGatewaySetting.php @@ -0,0 +1,50 @@ + '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); + } +} diff --git a/app/Services/Billing/BillingClient.php b/app/Services/Billing/BillingClient.php index 4c7caef..e2ab6a9 100644 --- a/app/Services/Billing/BillingClient.php +++ b/app/Services/Billing/BillingClient.php @@ -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 $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 */ + 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(); + } } diff --git a/app/Services/Events/EventRegistrationService.php b/app/Services/Events/EventRegistrationService.php index bbd27f6..102101b 100644 --- a/app/Services/Events/EventRegistrationService.php +++ b/app/Services/Events/EventRegistrationService.php @@ -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 $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; diff --git a/app/Services/Events/SubscriptionService.php b/app/Services/Events/SubscriptionService.php new file mode 100644 index 0000000..5286a16 --- /dev/null +++ b/app/Services/Events/SubscriptionService.php @@ -0,0 +1,257 @@ +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.']; + } +} diff --git a/app/Services/Payments/MerchantGatewayService.php b/app/Services/Payments/MerchantGatewayService.php new file mode 100644 index 0000000..f41c599 --- /dev/null +++ b/app/Services/Payments/MerchantGatewayService.php @@ -0,0 +1,288 @@ +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 $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} + */ + 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 $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} + */ + 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 $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} + */ + 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 $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} + */ + 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, + ]; + } +} diff --git a/config/events.php b/config/events.php index cbc2b09..72c5914 100644 --- a/config/events.php +++ b/config/events.php @@ -8,4 +8,30 @@ return [ 'meet_app_url' => env('LADILL_MEET_APP_URL', 'https://meet.ladill.com'), + 'pro' => [ + 'enabled' => filter_var(env('EVENTS_PRO_ENABLED', true), FILTER_VALIDATE_BOOLEAN), + 'price_minor' => (int) env('EVENTS_PRO_PRICE_MINOR', 4900), + 'currency' => env('EVENTS_PRO_CURRENCY', 'GHS'), + 'period_days' => (int) env('EVENTS_PRO_PERIOD_DAYS', 30), + 'grace_days' => (int) env('EVENTS_PRO_GRACE_DAYS', 3), + ], + + 'plans' => [ + 'pro' => ['price_minor' => (int) env('EVENTS_PRO_PRICE_MINOR', 4900)], + 'enterprise' => ['price_minor' => (int) env('EVENTS_ENTERPRISE_PRICE_MINOR', 14900)], + ], + + 'prepaid_months' => [6, 12, 24], + + 'free' => [ + 'max_live_events' => (int) env('EVENTS_FREE_MAX_LIVE_EVENTS', 2), + 'max_tickets_per_month' => (int) env('EVENTS_FREE_MAX_TICKETS_PER_MONTH', 100), + ], + + 'upgrade_banner' => [ + 'title' => 'Unlock Events Pro or Business', + 'description' => 'Unlimited events & tickets, with your own payment gateway — from GHS 49/mo.', + 'route' => 'events.pro.index', + ], + ]; diff --git a/config/ladill_launcher.php b/config/ladill_launcher.php index 2a0d785..93ad902 100644 --- a/config/ladill_launcher.php +++ b/config/ladill_launcher.php @@ -18,10 +18,7 @@ $root = config('app.platform_domain', 'ladill.com'); return [ 'apps' => [ - ['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'], ['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'], - ['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'], - ['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'], ['name' => 'Woo Manager', 'url' => 'https://woo.'.$root.'/sso/connect?redirect='.urlencode('https://woo.'.$root.'/dashboard'), 'icon' => 'woomanager.svg'], ['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'], ['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'], diff --git a/database/migrations/2026_07_15_010000_create_payment_gateway_settings_table.php b/database/migrations/2026_07_15_010000_create_payment_gateway_settings_table.php new file mode 100644 index 0000000..cc0e382 --- /dev/null +++ b/database/migrations/2026_07_15_010000_create_payment_gateway_settings_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('owner_ref', 64)->unique(); + $table->string('provider', 32); // paystack|flutterwave|hubtel + $table->text('public_key')->nullable(); + $table->text('secret_key')->nullable(); + $table->text('webhook_secret')->nullable(); + $table->boolean('is_active')->default(true); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('payment_gateway_settings'); + } +}; diff --git a/database/migrations/2026_07_15_020000_create_events_pro_subscriptions_table.php b/database/migrations/2026_07_15_020000_create_events_pro_subscriptions_table.php new file mode 100644 index 0000000..efd2f86 --- /dev/null +++ b/database/migrations/2026_07_15_020000_create_events_pro_subscriptions_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->string('status', 16)->default('active'); + $table->string('plan', 16)->default('pro'); + $table->unsignedInteger('price_minor'); + $table->char('currency', 3)->default('GHS'); + $table->boolean('auto_renew')->default(true); + $table->timestamp('started_at')->nullable(); + $table->timestamp('current_period_end')->nullable(); + $table->timestamp('last_charged_at')->nullable(); + $table->timestamp('canceled_at')->nullable(); + $table->string('last_reference')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamps(); + + $table->index(['status', 'current_period_end']); + }); + } + + public function down(): void + { + Schema::dropIfExists('events_pro_subscriptions'); + } +}; diff --git a/resources/views/components/plan-tier-price.blade.php b/resources/views/components/plan-tier-price.blade.php new file mode 100644 index 0000000..aaf5f86 --- /dev/null +++ b/resources/views/components/plan-tier-price.blade.php @@ -0,0 +1,23 @@ +@props([ + 'currency' => 'GHS', + 'monthlyMinor' => 0, + 'prepaidMinor' => null, + 'monthlyDisplay' => null, + 'monthlySuffix' => '/mo', + 'dark' => false, +]) + +@php + $prepaidBase = (int) ($prepaidMinor ?? $monthlyMinor); + $monthlyFormatted = $monthlyDisplay ?? number_format((int) $monthlyMinor / 100, 0); + $muted = $dark ? 'text-slate-300' : 'text-slate-500'; + $text = $dark ? 'text-white' : 'text-slate-900'; +@endphp + +

merge(['class' => "mt-2 text-3xl font-bold {$text}"]) }}> + {{ $currency }} {{ $monthlyFormatted }}{{ $monthlySuffix }} + + + + +

diff --git a/resources/views/events/dashboard.blade.php b/resources/views/events/dashboard.blade.php index 33d0547..0acbc04 100644 --- a/resources/views/events/dashboard.blade.php +++ b/resources/views/events/dashboard.blade.php @@ -2,6 +2,7 @@ Overview @php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
+ @include('partials.upgrade-banner')

Events

diff --git a/resources/views/events/payouts.blade.php b/resources/views/events/payouts.blade.php index c42a594..c58681a 100644 --- a/resources/views/events/payouts.blade.php +++ b/resources/views/events/payouts.blade.php @@ -16,7 +16,7 @@

Payments & Payouts

-

Ticket and contribution revenue settles into your Ladill wallet (5.5% tickets / 3.5% contributions).

+

Paid tickets and contributions settle directly to your connected gateway (0% Ladill fee). This wallet is for app subscriptions and older balances only.

@@ -97,7 +97,7 @@

Event payments

-

Recent ticket sales and contributions collected through Ladill Pay.

+

Legacy wallet credits from before BYO gateway settlement. New ticket revenue does not land here.

@if($transactions->isEmpty()) diff --git a/resources/views/events/pro/index.blade.php b/resources/views/events/pro/index.blade.php new file mode 100644 index 0000000..7aa730c --- /dev/null +++ b/resources/views/events/pro/index.blade.php @@ -0,0 +1,100 @@ + + @php + $proPrice = number_format($proPriceMinor / 100, 0); + $enterprisePrice = number_format($enterprisePriceMinor / 100, 0); + $live = $subscription && $subscription->entitled(); + @endphp + +
+ @if (session('success')) +
{{ session('success') }}
+ @endif + @if (session('error')) +
{{ session('error') }}
+ @endif + @if (session('upsell')) +
{{ session('upsell') }}
+ @endif + +
+

Choose your Events plan

+

Pay monthly from your Ladill wallet, or prepay 6/12/24 months via Paystack.

+ @if ($gatingActive && ! $live) +
+ + @foreach ($prepaidMonths as $months) + + @endforeach +
+ @endif +
+ + @if ($live) +
+
+
+ {{ $isEnterprise ? 'Business' : 'Pro' }} active until {{ $subscription->current_period_end->format('d M Y') }} +
+ @if ($subscription->auto_renew) +
@csrf
+ @elseif ($subscription->status === 'canceled') +
@csrf
+ @endif +
+
+ @endif + +
+
+

Free

+

GHS 0

+
    +
  • 2 live events
  • +
  • 100 tickets / month
  • +
  • BYO Paystack / Flutterwave / Hubtel
  • +
+ @if ($planKey === 'free')

Current plan

@endif +
+ +
+

Pro

+ +
    +
  • Unlimited events
  • +
  • Unlimited tickets
  • +
  • Priority support
  • +
+
+ @if ($planKey === 'pro') +

Current plan

+ @elseif ($gatingActive && ! $hasPaidPlan) +
@csrf
+ @foreach ($prepaidMonths as $months) +
@csrf
+ @endforeach + @endif +
+
+ +
+

Business

+ +
    +
  • Everything in Pro
  • +
  • Team seats
  • +
  • Advanced attendee tools
  • +
+
+ @if ($planKey === 'enterprise') +

Current plan

+ @elseif ($gatingActive && ! $hasPaidPlan) +
@csrf
+ @foreach ($prepaidMonths as $months) +
@csrf
+ @endforeach + @endif +
+
+
+
+
diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 4c9e738..7db4159 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -32,6 +32,11 @@
+ @php $proActive = request()->routeIs('events.pro.*'); @endphp + + + Plans + diff --git a/resources/views/partials/upgrade-banner.blade.php b/resources/views/partials/upgrade-banner.blade.php new file mode 100644 index 0000000..8167d3f --- /dev/null +++ b/resources/views/partials/upgrade-banner.blade.php @@ -0,0 +1,10 @@ +@php $banner = config('events.upgrade_banner'); @endphp +@if (empty($hasPaidPlan) && ! empty($banner)) + +@endif diff --git a/resources/views/qr/account/settings.blade.php b/resources/views/qr/account/settings.blade.php index 1432327..574f152 100644 --- a/resources/views/qr/account/settings.blade.php +++ b/resources/views/qr/account/settings.blade.php @@ -269,6 +269,46 @@
+
+
+

Payment gateway

+

Connect Paystack, Flutterwave, or Hubtel. Ticket and contribution payments go 100% to you — 0% Ladill platform fee.

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + @if ($gateway?->isConfigured()) +

Gateway connected ({{ ucfirst($gateway->provider) }}).

+ @else +

Paid checkouts stay disabled until a gateway is connected.

+ @endif +
+ diff --git a/routes/console.php b/routes/console.php index 3aede45..4778ea7 100644 --- a/routes/console.php +++ b/routes/console.php @@ -15,3 +15,5 @@ Schedule::command('hosting:process-expired-accounts')->daily()->withoutOverlappi Schedule::command('hosting:notify-expiring')->daily()->withoutOverlapping(); Schedule::command('hosting:retry-pending-fulfillment')->everyFifteenMinutes()->withoutOverlapping(); Schedule::command('ssl:renew')->daily()->withoutOverlapping(); + +Schedule::command('events:pro-renew')->dailyAt('02:55')->withoutOverlapping(); diff --git a/routes/web.php b/routes/web.php index 259b56d..cb75894 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,6 +5,7 @@ use App\Http\Controllers\WalletBalanceController; use App\Http\Controllers\Events\AttendeeController; use App\Http\Controllers\Events\OverviewController; use App\Http\Controllers\Events\PayoutsController; +use App\Http\Controllers\Events\ProController; use App\Http\Controllers\Events\ProgrammeController; use App\Http\Controllers\Events\SpeakerController; use App\Http\Controllers\NotificationController; @@ -99,6 +100,13 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::put('/events/{event}/speakers', [SpeakerController::class, 'update'])->name('speakers.update'); Route::post('/events/{event}/speakers/invite', [SpeakerController::class, 'sendInvite'])->name('speakers.invite'); + + Route::get('/pro', [ProController::class, 'index'])->name('events.pro.index'); + Route::post('/pro/subscribe', [ProController::class, 'subscribe'])->name('events.pro.subscribe'); + Route::post('/pro/subscribe-enterprise', [ProController::class, 'subscribeEnterprise'])->name('events.pro.subscribe-enterprise'); + Route::post('/pro/subscribe-prepaid', [ProController::class, 'subscribePrepaid'])->name('events.pro.subscribe-prepaid'); + Route::get('/pro/paystack/callback', [ProController::class, 'paystackCallback'])->name('events.pro.paystack.callback'); + Route::post('/pro/cancel', [ProController::class, 'cancel'])->name('events.pro.cancel'); Route::get('/payouts', [PayoutsController::class, 'index'])->name('events.payouts'); Route::get('/payouts/banks', [PayoutsController::class, 'banks'])->name('events.payouts.banks'); Route::put('/payouts/payout-account', [PayoutsController::class, 'updatePayoutAccount'])->name('events.payouts.payout-account'); diff --git a/tests/Feature/EventsProTest.php b/tests/Feature/EventsProTest.php new file mode 100644 index 0000000..ce997a3 --- /dev/null +++ b/tests/Feature/EventsProTest.php @@ -0,0 +1,78 @@ +withoutMiddleware(EnsurePlatformSession::class); + config([ + 'billing.api_url' => 'https://ladill.com/api/billing', + 'billing.api_key' => 'events-billing-key', + 'events.pro.enabled' => true, + 'events.pro.price_minor' => 4900, + 'events.plans.pro.price_minor' => 4900, + 'events.plans.enterprise.price_minor' => 14900, + 'events.pro.period_days' => 30, + 'events.pro.grace_days' => 3, + 'events.free.max_live_events' => 2, + 'events.free.max_tickets_per_month' => 100, + ]); + } + + private function user(): User + { + return User::factory()->create(['public_id' => 'usr_'.uniqid()]); + } + + public function test_subscribe_charges_wallet_and_activates(): void + { + Http::fake(['*/debit' => Http::response(['id' => 1], 201)]); + $user = $this->user(); + $svc = app(SubscriptionService::class); + + [$ok] = $svc->subscribe($user); + + $this->assertTrue($ok); + $this->assertTrue($svc->isPro($user)); + Http::assertSent(fn ($r) => str_ends_with(parse_url($r->url(), PHP_URL_PATH) ?: '', '/debit') + && $r['amount_minor'] === 4900 + && $r['service'] === 'events'); + } + + public function test_subscribe_enterprise_charges_wallet_and_activates(): void + { + Http::fake(['*/debit' => Http::response(['id' => 1], 201)]); + $user = $this->user(); + $svc = app(SubscriptionService::class); + + [$ok] = $svc->subscribeEnterprise($user); + + $this->assertTrue($ok); + $this->assertTrue($svc->isEnterprise($user)); + $sub = ProSubscription::where('user_id', $user->id)->first(); + $this->assertSame('enterprise', $sub->plan); + $this->assertSame(14900, $sub->price_minor); + } + + public function test_free_user_is_gated_to_event_limit(): void + { + $user = $this->user(); + $svc = app(SubscriptionService::class); + + $this->assertTrue($svc->canCreateEvent($user)); + $this->assertSame(0, $svc->liveEventCount($user)); + } +}