diff --git a/app/Http/Controllers/Frontdesk/ProController.php b/app/Http/Controllers/Frontdesk/ProController.php index 9eedf72..a15e65a 100644 --- a/app/Http/Controllers/Frontdesk/ProController.php +++ b/app/Http/Controllers/Frontdesk/ProController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Frontdesk; use App\Http\Controllers\Controller; use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount; +use App\Models\Organization; use App\Services\Billing\BillingClient; use App\Services\Frontdesk\FrontdeskPermissions; use App\Services\Frontdesk\PlanService; @@ -24,21 +25,20 @@ class ProController extends Controller $expiresAt = ! empty($settings['plan_expires_at']) ? Carbon::parse($settings['plan_expires_at']) : null; + $planKey = $plans->planKey($organization); return view('frontdesk.pro.index', [ 'organization' => $organization, - 'isPro' => $plans->isPro($organization), + 'planKey' => $planKey, + 'isPro' => $planKey === 'pro', + 'isEnterprise' => $planKey === 'enterprise', + 'hasPaidPlan' => $plans->hasPaidPlan($organization), 'canManage' => $canManage, - 'priceMinor' => $plans->proPriceMinor(), + 'proPriceMinor' => $plans->proPriceMinor(), + 'prepaidMonths' => (array) config('frontdesk.prepaid_months', [6, 12, 24]), 'currency' => (string) config('billing.currency', 'GHS'), 'planExpiresAt' => $expiresAt, - 'proFeatures' => [ - 'Unlimited branches & kiosk devices', - 'Unlimited host email alerts', - 'Webhooks & calendar (iCal) feeds', - 'Scheduled report exports', - 'Custom badge templates', - ], + 'salesUrl' => ladill_platform_url('contact-sales'), ]); } @@ -47,26 +47,128 @@ class ProController extends Controller $this->authorizeAbility($request, 'settings.manage'); $organization = $this->organization($request); - if ($plans->isPro($organization)) { + if ($plans->hasPaidPlan($organization)) { return redirect()->route('frontdesk.pro.index') - ->with('success', 'Your organization is already on Frontdesk Pro.'); + ->with('success', 'Your organization already has an active paid plan.'); } - $priceMinor = $plans->proPriceMinor(); - $ownerRef = $organization->owner_ref; + if ($plans->isEnterprise($organization)) { + return redirect()->route('frontdesk.pro.index') + ->with('success', 'Your organization is on Frontdesk Enterprise.'); + } + + return $this->chargeMonthlyWallet( + $organization, + $billing, + $plans->proPriceMinor(), + 'pro', + 'frontdesk_pro', + 'frontdesk-pro-'.$organization->id.'-'.now()->format('Y-m-d-His'), + 'Ladill Frontdesk Pro — monthly subscription', + 'Welcome to Frontdesk Pro — active for one month.', + ); + } + + public function subscribePrepaid(Request $request, PlanService $plans, BillingClient $billing): RedirectResponse + { + $this->authorizeAbility($request, 'settings.manage'); + $validated = $request->validate([ + 'plan' => ['required', 'in:pro'], + 'months' => ['required', 'integer', 'in:6,12,24'], + ]); + + $organization = $this->organization($request); + if ($plans->hasPaidPlan($organization) || $plans->isEnterprise($organization)) { + return redirect()->route('frontdesk.pro.index') + ->with('success', 'Your organization already has an active plan.'); + } + + $months = (int) $validated['months']; + $amountMinor = $plans->proPriceMinor() * $months; try { - if (! $billing->canAfford($ownerRef, $priceMinor)) { + $checkout = $billing->initiatePlanCheckout( + $organization->owner_ref, + 'pro', + $months, + $amountMinor, + route('frontdesk.pro.paystack.callback'), + ['organization_id' => $organization->id], + ); + } catch (\Throwable) { + return redirect()->route('frontdesk.pro.index') + ->with('error', 'Could not start Paystack checkout. Please try again.'); + } + + $url = trim((string) ($checkout['checkout_url'] ?? '')); + if ($url === '') { + return redirect()->route('frontdesk.pro.index') + ->with('error', 'Paystack did not return a checkout URL.'); + } + + return redirect()->away($url); + } + + public function paystackCallback(Request $request, BillingClient $billing, PlanService $plans): RedirectResponse + { + $reference = (string) $request->query('reference', ''); + if ($reference === '') { + return redirect()->route('frontdesk.pro.index')->with('error', 'Missing payment reference.'); + } + + try { + $result = $billing->verifyPlanCheckout($reference); + } catch (\Throwable) { + return redirect()->route('frontdesk.pro.index') + ->with('error', 'Could not verify payment. Contact support with reference: '.$reference); + } + + if (! ($result['paid'] ?? false)) { + return redirect()->route('frontdesk.pro.index') + ->with('error', 'Payment was not completed.'); + } + + $metadata = (array) ($result['metadata'] ?? []); + $organization = Organization::query()->find((int) ($metadata['organization_id'] ?? 0)); + if (! $organization) { + return redirect()->route('frontdesk.pro.index') + ->with('error', 'Organization not found for this payment.'); + } + + $this->authorizeAbility($request, 'settings.manage'); + if ($this->organization($request)->id !== $organization->id) { + abort(403); + } + + $months = (int) ($result['months'] ?? 0); + $this->activatePrepaidPlan($organization, 'pro', $months); + + return redirect()->route('frontdesk.pro.index') + ->with('success', "Frontdesk Pro is active for {$months} months."); + } + + private function chargeMonthlyWallet( + Organization $organization, + BillingClient $billing, + int $priceMinor, + string $plan, + string $billingSource, + string $reference, + string $description, + string $successMessage, + ): RedirectResponse { + try { + if (! $billing->canAfford($organization->owner_ref, $priceMinor)) { return redirect()->route('frontdesk.pro.index') - ->with('error', 'Insufficient wallet balance. Top up your Ladill wallet to upgrade to Pro.'); + ->with('error', 'Insufficient wallet balance. Top up your Ladill wallet to upgrade.'); } $charged = $billing->debit( - $ownerRef, + $organization->owner_ref, $priceMinor, - 'frontdesk_pro', - 'frontdesk-pro-'.$organization->id.'-'.now()->format('Y-m-d-His'), - 'Ladill Frontdesk Pro — monthly subscription', + $billingSource, + $reference, + $description, ); } catch (\Throwable) { return redirect()->route('frontdesk.pro.index') @@ -75,19 +177,30 @@ class ProController extends Controller if (! $charged) { return redirect()->route('frontdesk.pro.index') - ->with('error', 'Insufficient wallet balance. Top up your Ladill wallet to upgrade to Pro.'); + ->with('error', 'Insufficient wallet balance. Top up your Ladill wallet to upgrade.'); } $settings = $organization->settings ?? []; - $settings['plan'] = 'pro'; + $settings['plan'] = $plan; $settings['auto_renew'] = true; + $settings['billing_method'] = 'wallet_monthly'; $settings['plan_expires_at'] = now() ->addDays((int) config('frontdesk.pro.period_days', 30)) ->toIso8601String(); unset($settings['plan_renewal_error']); $organization->update(['settings' => $settings]); - return redirect()->route('frontdesk.pro.index') - ->with('success', 'Welcome to Frontdesk Pro — active for one month.'); + return redirect()->route('frontdesk.pro.index')->with('success', $successMessage); + } + + private function activatePrepaidPlan(Organization $organization, string $plan, int $months): void + { + $settings = $organization->settings ?? []; + $settings['plan'] = $plan; + $settings['auto_renew'] = false; + $settings['billing_method'] = 'paystack_prepaid'; + $settings['plan_expires_at'] = now()->addMonths($months)->toIso8601String(); + unset($settings['plan_renewal_error']); + $organization->update(['settings' => $settings]); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4d0d60f..31f136d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -37,16 +37,28 @@ class AppServiceProvider extends ServiceProvider View::composer('partials.sidebar', function ($view) { /** @var User|null $user */ $user = auth()->user(); + $planKey = 'free'; $isPro = false; + $isEnterprise = false; + $hasPaidPlan = false; if ($user) { $organization = app(OrganizationResolver::class)->resolveForUser($user); if ($organization) { - $isPro = app(PlanService::class)->isPro($organization); + $plans = app(PlanService::class); + $planKey = $plans->planKey($organization); + $isPro = $planKey === 'pro'; + $isEnterprise = $planKey === 'enterprise'; + $hasPaidPlan = $plans->hasPaidPlan($organization); } } - $view->with('isPro', $isPro); + $view->with([ + 'planKey' => $planKey, + 'isPro' => $isPro, + 'isEnterprise' => $isEnterprise, + 'hasPaidPlan' => $hasPaidPlan, + ]); }); View::composer(['partials.topbar'], function ($view) { diff --git a/app/Services/Billing/BillingClient.php b/app/Services/Billing/BillingClient.php index 4b97295..7a8782b 100644 --- a/app/Services/Billing/BillingClient.php +++ b/app/Services/Billing/BillingClient.php @@ -61,4 +61,48 @@ class BillingClient return true; } + + /** + * @param array $metadata + * @return array{checkout_url: string, reference: string} + */ + public function initiatePlanCheckout( + string $publicId, + 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' => $publicId, + 'app' => (string) config('billing.service', 'frontdesk'), + '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/Frontdesk/PlanService.php b/app/Services/Frontdesk/PlanService.php index e5b7f88..ae64c25 100644 --- a/app/Services/Frontdesk/PlanService.php +++ b/app/Services/Frontdesk/PlanService.php @@ -13,9 +13,8 @@ class PlanService $settings = $organization->settings ?? []; $plan = (string) ($settings['plan'] ?? 'free'); - if ($plan === 'pro' && ! empty($settings['plan_expires_at'])) { - $expires = Carbon::parse($settings['plan_expires_at']); - if ($expires->isPast()) { + if (in_array($plan, ['pro', 'enterprise'], true) && ! empty($settings['plan_expires_at'])) { + if (Carbon::parse($settings['plan_expires_at'])->isPast()) { return 'free'; } } @@ -28,6 +27,17 @@ class PlanService return $this->planKey($organization) === 'pro'; } + public function isEnterprise(Organization $organization): bool + { + return $this->planKey($organization) === 'enterprise'; + } + + /** Self-serve paid plans only — enterprise is sales-led. */ + public function hasPaidPlan(Organization $organization): bool + { + return $this->planKey($organization) === 'pro'; + } + /** @return array */ public function plan(Organization $organization): array { diff --git a/config/frontdesk.php b/config/frontdesk.php index 55b55dc..b00e5c1 100644 --- a/config/frontdesk.php +++ b/config/frontdesk.php @@ -158,8 +158,26 @@ return [ 'custom_badge_templates', ], ], + 'enterprise' => [ + 'label' => 'Enterprise', + 'max_branches' => null, + 'max_kiosk_devices' => null, + 'free_emails_per_month' => null, + 'features' => [ + 'check_in', + 'hosts', + 'badges', + 'watchlist', + 'integrations', + 'webhooks', + 'scheduled_reports', + 'custom_badge_templates', + ], + ], ], + 'prepaid_months' => [6, 12, 24], + 'notification_events' => [ 'visitor_arrived' => 'Visitor arrived (checked in)', 'visitor_checked_out' => 'Visitor checked out', diff --git a/resources/views/frontdesk/pro/index.blade.php b/resources/views/frontdesk/pro/index.blade.php index 54e1e80..7a21d2a 100644 --- a/resources/views/frontdesk/pro/index.blade.php +++ b/resources/views/frontdesk/pro/index.blade.php @@ -1,56 +1,124 @@ - + @php - $price = number_format($priceMinor / 100, 2); + $proPrice = number_format($proPriceMinor / 100, 0); @endphp -
+
@if (session('upsell')) -
{{ session('upsell') }}
+
{{ session('upsell') }}
+ @endif + @if (session('success')) +
{{ session('success') }}
+ @endif + @if (session('error')) +
{{ session('error') }}
@endif -
-
-
- Pro - @if ($isPro) - Active - @endif -
-

Ladill Frontdesk Pro

-

Everything in Free, plus unlimited scale, integrations, and unlimited host email alerts.

-

{{ $currency }} {{ $price }} / month

+
+

Choose your Frontdesk plan

+

+ Pay monthly from your Ladill wallet, or prepay 6/12/24 months via Paystack for Pro. + Enterprise is custom — contact sales for multi-site onboarding. +

+
+ + @foreach ($prepaidMonths as $months) + + @endforeach +
+
+ +
+ {{-- Free --}} +
+

Free

+

GHS 0

+

1 branch · 1 kiosk

+
    +
  • Core check-in & badges
  • +
  • 100 host emails / month
  • +
  • Watchlist & hosts
  • +
+ @if ($planKey === 'free') +

Current plan

+ @endif
-
-