diff --git a/app/Http/Controllers/Qms/ProController.php b/app/Http/Controllers/Qms/ProController.php index 68a0413..2f6b4d3 100644 --- a/app/Http/Controllers/Qms/ProController.php +++ b/app/Http/Controllers/Qms/ProController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Qms; use App\Http\Controllers\Controller; use App\Http\Controllers\Qms\Concerns\ScopesToAccount; +use App\Models\Organization; use App\Services\Billing\BillingClient; use App\Services\Qms\PlanService; use App\Services\Qms\QmsPermissions; @@ -41,6 +42,7 @@ class ProController extends Controller 'branchCount' => $branchCount, 'canSubscribeEnterprise' => $plans->canSubscribeEnterprise($organization), 'enterpriseMinBranches' => (int) config('qms.enterprise.min_branches', 2), + 'prepaidMonths' => (array) config('qms.prepaid_months', [6, 12, 24]), 'currency' => (string) config('billing.currency', 'GHS'), 'planExpiresAt' => $expiresAt, 'enterpriseBilledBranches' => (int) ($settings['enterprise_billed_branches'] ?? $branchCount), @@ -58,7 +60,7 @@ class ProController extends Controller ->with('success', 'Your organization already has an active paid plan.'); } - return $this->chargePlan( + return $this->chargeMonthlyWallet( $organization, $billing, $plans->proPriceMinor(), @@ -87,12 +89,10 @@ class ProController extends Controller ->with('error', "Enterprise billing requires at least {$min} active branches. Add another branch or choose Pro for a single site."); } - $priceMinor = $plans->enterprisePriceMinor($organization); - - return $this->chargePlan( + return $this->chargeMonthlyWallet( $organization, $billing, - $priceMinor, + $plans->enterprisePriceMinor($organization), 'enterprise', 'queue_enterprise', 'queue-enterprise-'.$organization->id.'-'.now()->format('Y-m-d-His'), @@ -102,8 +102,109 @@ class ProController extends Controller ); } - private function chargePlan( - $organization, + public function subscribePrepaid(Request $request, PlanService $plans, BillingClient $billing): RedirectResponse + { + $this->authorizeAbility($request, 'settings.manage'); + $validated = $request->validate([ + 'plan' => ['required', 'in:pro,enterprise'], + 'months' => ['required', 'integer', 'in:6,12,24'], + ]); + + $organization = $this->organization($request); + if ($plans->hasPaidPlan($organization)) { + return redirect()->route('qms.pro.index') + ->with('success', 'Your organization already has an active paid plan.'); + } + + $plan = $validated['plan']; + $months = (int) $validated['months']; + + if ($plan === 'enterprise' && ! $plans->canSubscribeEnterprise($organization)) { + $min = (int) config('qms.enterprise.min_branches', 2); + + return redirect()->route('qms.pro.index') + ->with('error', "Enterprise billing requires at least {$min} active branches."); + } + + $monthlyMinor = $plan === 'enterprise' + ? $plans->enterprisePriceMinor($organization) + : $plans->proPriceMinor(); + $amountMinor = $monthlyMinor * $months; + $branches = $plan === 'enterprise' ? $plans->activeBranchCount($organization) : null; + + try { + $checkout = $billing->initiatePlanCheckout( + $organization->owner_ref, + $plan, + $months, + $amountMinor, + route('qms.pro.paystack.callback'), + array_filter([ + 'organization_id' => $organization->id, + 'enterprise_billed_branches' => $branches, + ], static fn ($v) => $v !== null), + ); + } catch (\Throwable) { + return redirect()->route('qms.pro.index') + ->with('error', 'Could not start Paystack checkout. Please try again.'); + } + + $url = trim((string) ($checkout['checkout_url'] ?? '')); + if ($url === '') { + return redirect()->route('qms.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('qms.pro.index')->with('error', 'Missing payment reference.'); + } + + try { + $result = $billing->verifyPlanCheckout($reference); + } catch (\Throwable) { + return redirect()->route('qms.pro.index') + ->with('error', 'Could not verify payment. Contact support with reference: '.$reference); + } + + if (! ($result['paid'] ?? false)) { + return redirect()->route('qms.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('qms.pro.index') + ->with('error', 'Organization not found for this payment.'); + } + + $this->authorizeAbility($request, 'settings.manage'); + if ($this->organization($request)->id !== $organization->id) { + abort(403); + } + + $plan = (string) ($result['plan'] ?? 'pro'); + $months = (int) ($result['months'] ?? 0); + $branches = isset($metadata['enterprise_billed_branches']) + ? (int) $metadata['enterprise_billed_branches'] + : null; + + $this->activatePrepaidPlan($organization, $plan, $months, $branches); + + $label = $plan === 'enterprise' ? 'Queue Enterprise' : 'Queue Pro'; + + return redirect()->route('qms.pro.index') + ->with('success', "{$label} is active for {$months} months."); + } + + private function chargeMonthlyWallet( + Organization $organization, BillingClient $billing, int $priceMinor, string $plan, @@ -139,6 +240,7 @@ class ProController extends Controller $settings = $organization->settings ?? []; $settings['plan'] = $plan; $settings['auto_renew'] = true; + $settings['billing_method'] = 'wallet_monthly'; $settings['plan_expires_at'] = now() ->addDays((int) config('qms.pro.period_days', 30)) ->toIso8601String(); @@ -150,4 +252,22 @@ class ProController extends Controller return redirect()->route('qms.pro.index')->with('success', $successMessage); } + + private function activatePrepaidPlan( + Organization $organization, + string $plan, + int $months, + ?int $enterpriseBranches = null, + ): void { + $settings = $organization->settings ?? []; + $settings['plan'] = $plan; + $settings['auto_renew'] = false; + $settings['billing_method'] = 'paystack_prepaid'; + $settings['plan_expires_at'] = now()->addMonths($months)->toIso8601String(); + if ($enterpriseBranches !== null) { + $settings['enterprise_billed_branches'] = $enterpriseBranches; + } + unset($settings['plan_renewal_error']); + $organization->update(['settings' => $settings]); + } } diff --git a/app/Services/Billing/BillingClient.php b/app/Services/Billing/BillingClient.php index 4b97295..bdbc03f 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', 'queue'), + '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/config/qms.php b/config/qms.php index d790628..60e5558 100644 --- a/config/qms.php +++ b/config/qms.php @@ -203,6 +203,8 @@ return [ 'min_branches' => (int) env('QUEUE_ENTERPRISE_MIN_BRANCHES', 2), ], + 'prepaid_months' => [6, 12, 24], + 'rule_types' => [ 'overflow' => 'Overflow routing', 'priority_boost' => 'Priority boost', diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index d8c488e..eef1be8 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -89,8 +89,8 @@ @endif - @if ($permissions->can($member, 'settings.view')) -
+
+ @if ($permissions->can($member, 'settings.view')) @@ -98,18 +98,16 @@ Settings -
- @endif + @endif -
@php $proActive = request()->routeIs('qms.pro.*'); @endphp @if (! empty($hasPaidPlan)) - + {{ ! empty($isEnterprise) ? 'Queue Enterprise' : 'Queue Pro' }} @else - + Upgrade to Pro diff --git a/resources/views/qms/pro/index.blade.php b/resources/views/qms/pro/index.blade.php index 0b52aaa..61f18d9 100644 --- a/resources/views/qms/pro/index.blade.php +++ b/resources/views/qms/pro/index.blade.php @@ -5,7 +5,7 @@ $enterpriseTotal = number_format($enterprisePriceMinor / 100, 0); @endphp -
+
@if (session('success'))
{{ session('success') }}
@endif @@ -15,7 +15,20 @@

Choose your Queue plan

-

Billed monthly from your Ladill wallet. Enterprise includes a one-time setup fee — contact sales for onboarding.

+

+ Pay monthly from your Ladill wallet, or prepay 6/12/24 months via Paystack. + Enterprise includes a one-time setup fee — contact sales for onboarding. +

+
+ + @foreach ($prepaidMonths as $months) + + @endforeach +
@@ -38,7 +51,10 @@

Pro

{{ $currency }} {{ $proPrice }}/mo

-

Unlimited branches & queues

+

+ +

+

Unlimited branches & queues

  • Advanced routing rules
  • Unlimited kiosks & displays
  • @@ -55,10 +71,18 @@ .

    @elseif ($canManage) -
    + @csrf - +
    + @else

    Ask an admin to upgrade.

    @endif @@ -96,12 +120,21 @@

    @elseif ($canManage) @if ($canSubscribeEnterprise) -
    + @csrf
    + @else

    Add at least {{ $enterpriseMinBranches }} active branches, or choose Pro for a single site.

    @endif diff --git a/routes/web.php b/routes/web.php index 6c84b84..c974f83 100644 --- a/routes/web.php +++ b/routes/web.php @@ -173,5 +173,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/pro', [ProController::class, 'index'])->name('qms.pro.index'); Route::post('/pro/subscribe', [ProController::class, 'subscribe'])->name('qms.pro.subscribe'); Route::post('/pro/subscribe-enterprise', [ProController::class, 'subscribeEnterprise'])->name('qms.pro.subscribe-enterprise'); + Route::post('/pro/subscribe-prepaid', [ProController::class, 'subscribePrepaid'])->name('qms.pro.subscribe-prepaid'); + Route::get('/pro/paystack/callback', [ProController::class, 'paystackCallback'])->name('qms.pro.paystack.callback'); }); }); diff --git a/tests/Feature/QmsProTest.php b/tests/Feature/QmsProTest.php index cae00e5..ed75ece 100644 --- a/tests/Feature/QmsProTest.php +++ b/tests/Feature/QmsProTest.php @@ -182,4 +182,21 @@ class QmsProTest extends TestCase $this->assertSame('enterprise', $settings['plan']); $this->assertSame(2, $settings['enterprise_billed_branches']); } + + public function test_prepaid_checkout_redirects_to_paystack(): void + { + Http::fake([ + 'billing.test/plan-checkout' => Http::response([ + 'checkout_url' => 'https://checkout.paystack.test/pay', + 'reference' => 'SAP-QUEUE-TEST', + ], 201), + ]); + + $this->actingAs($this->owner) + ->post(route('qms.pro.subscribe-prepaid'), [ + 'plan' => 'pro', + 'months' => 12, + ]) + ->assertRedirect('https://checkout.paystack.test/pay'); + } }