Bill Queue Pro per active branch like Enterprise.
Deploy Ladill Queue / deploy (push) Successful in 33s

Show /branch/mo pricing with live branch totals on the plans page,
charge subscribe/prepaid/renewals by branch count, and store pro_billed_branches.
This commit is contained in:
isaacclad
2026-07-16 09:33:03 +00:00
parent 4e45ef4b65
commit 8892316664
6 changed files with 163 additions and 37 deletions
+52 -19
View File
@@ -27,7 +27,11 @@ class ProController extends Controller
: null; : null;
$planKey = $plans->planKey($organization); $planKey = $plans->planKey($organization);
$branchCount = $plans->activeBranchCount($organization); $branchCount = $plans->activeBranchCount($organization);
$proPrice = $plans->proPriceMinor($organization);
$enterprisePrice = $plans->enterprisePriceMinor($organization); $enterprisePrice = $plans->enterprisePriceMinor($organization);
$billedBranches = (int) ($settings['pro_billed_branches']
?? $settings['enterprise_billed_branches']
?? $branchCount);
return view('qms.pro.index', [ return view('qms.pro.index', [
'organization' => $organization, 'organization' => $organization,
@@ -36,7 +40,8 @@ class ProController extends Controller
'isEnterprise' => $planKey === 'enterprise', 'isEnterprise' => $planKey === 'enterprise',
'hasPaidPlan' => $plans->hasPaidPlan($organization), 'hasPaidPlan' => $plans->hasPaidPlan($organization),
'canManage' => $canManage, 'canManage' => $canManage,
'proPriceMinor' => $plans->proPriceMinor(), 'proPricePerBranchMinor' => $plans->proPricePerBranchMinor(),
'proPriceMinor' => $proPrice,
'enterprisePricePerBranchMinor' => $plans->enterprisePricePerBranchMinor(), 'enterprisePricePerBranchMinor' => $plans->enterprisePricePerBranchMinor(),
'enterprisePriceMinor' => $enterprisePrice, 'enterprisePriceMinor' => $enterprisePrice,
'branchCount' => $branchCount, 'branchCount' => $branchCount,
@@ -45,7 +50,9 @@ class ProController extends Controller
'prepaidMonths' => (array) config('qms.prepaid_months', [6, 12, 24]), 'prepaidMonths' => (array) config('qms.prepaid_months', [6, 12, 24]),
'currency' => (string) config('billing.currency', 'GHS'), 'currency' => (string) config('billing.currency', 'GHS'),
'planExpiresAt' => $expiresAt, 'planExpiresAt' => $expiresAt,
'proBilledBranches' => (int) ($settings['pro_billed_branches'] ?? $branchCount),
'enterpriseBilledBranches' => (int) ($settings['enterprise_billed_branches'] ?? $branchCount), 'enterpriseBilledBranches' => (int) ($settings['enterprise_billed_branches'] ?? $branchCount),
'billedBranches' => $billedBranches,
'salesUrl' => ladill_platform_url('contact-sales'), 'salesUrl' => ladill_platform_url('contact-sales'),
]); ]);
} }
@@ -63,12 +70,13 @@ class ProController extends Controller
return $this->chargeMonthlyWallet( return $this->chargeMonthlyWallet(
$organization, $organization,
$billing, $billing,
$plans->proPriceMinor(), $plans->proPriceMinor($organization),
'pro', 'pro',
'queue_pro', 'queue_pro',
'queue-pro-'.$organization->id.'-'.now()->format('Y-m-d-His'), 'queue-pro-'.$organization->id.'-'.now()->format('Y-m-d-His'),
'Ladill Queue Pro — monthly subscription', 'Ladill Queue Pro — monthly subscription',
'Welcome to Queue Pro — active for one month.', 'Welcome to Queue Pro — active for one month.',
$plans->activeBranchCount($organization),
); );
} }
@@ -128,9 +136,19 @@ class ProController extends Controller
$monthlyMinor = $plan === 'enterprise' $monthlyMinor = $plan === 'enterprise'
? $plans->enterprisePriceMinor($organization) ? $plans->enterprisePriceMinor($organization)
: $plans->proPriceMinor(); : $plans->proPriceMinor($organization);
$amountMinor = $monthlyMinor * $months; $amountMinor = $monthlyMinor * $months;
$branches = $plan === 'enterprise' ? $plans->activeBranchCount($organization) : null; $branches = $plans->activeBranchCount($organization);
$metadata = [
'organization_id' => $organization->id,
'billed_branches' => $branches,
];
if ($plan === 'enterprise') {
$metadata['enterprise_billed_branches'] = $branches;
} else {
$metadata['pro_billed_branches'] = $branches;
}
try { try {
$checkout = $billing->initiatePlanCheckout( $checkout = $billing->initiatePlanCheckout(
@@ -139,10 +157,7 @@ class ProController extends Controller
$months, $months,
$amountMinor, $amountMinor,
route('qms.pro.paystack.callback'), route('qms.pro.paystack.callback'),
array_filter([ $metadata,
'organization_id' => $organization->id,
'enterprise_billed_branches' => $branches,
], static fn ($v) => $v !== null),
); );
} catch (\Throwable) { } catch (\Throwable) {
return redirect()->route('qms.pro.index') return redirect()->route('qms.pro.index')
@@ -191,9 +206,13 @@ class ProController extends Controller
$plan = (string) ($result['plan'] ?? 'pro'); $plan = (string) ($result['plan'] ?? 'pro');
$months = (int) ($result['months'] ?? 0); $months = (int) ($result['months'] ?? 0);
$branches = isset($metadata['enterprise_billed_branches']) $branches = isset($metadata['billed_branches'])
? (int) $metadata['enterprise_billed_branches'] ? (int) $metadata['billed_branches']
: null; : (isset($metadata['pro_billed_branches'])
? (int) $metadata['pro_billed_branches']
: (isset($metadata['enterprise_billed_branches'])
? (int) $metadata['enterprise_billed_branches']
: null));
$this->activatePrepaidPlan($organization, $plan, $months, $branches); $this->activatePrepaidPlan($organization, $plan, $months, $branches);
@@ -212,7 +231,7 @@ class ProController extends Controller
string $reference, string $reference,
string $description, string $description,
string $successMessage, string $successMessage,
?int $enterpriseBranches = null, ?int $billedBranches = null,
): RedirectResponse { ): RedirectResponse {
try { try {
if (! $billing->canAfford($organization->owner_ref, $priceMinor)) { if (! $billing->canAfford($organization->owner_ref, $priceMinor)) {
@@ -244,9 +263,7 @@ class ProController extends Controller
$settings['plan_expires_at'] = now() $settings['plan_expires_at'] = now()
->addDays((int) config('qms.pro.period_days', 30)) ->addDays((int) config('qms.pro.period_days', 30))
->toIso8601String(); ->toIso8601String();
if ($enterpriseBranches !== null) { $this->applyBilledBranches($settings, $plan, $billedBranches);
$settings['enterprise_billed_branches'] = $enterpriseBranches;
}
unset($settings['plan_renewal_error']); unset($settings['plan_renewal_error']);
$organization->update(['settings' => $settings]); $organization->update(['settings' => $settings]);
@@ -257,17 +274,33 @@ class ProController extends Controller
Organization $organization, Organization $organization,
string $plan, string $plan,
int $months, int $months,
?int $enterpriseBranches = null, ?int $billedBranches = null,
): void { ): void {
$settings = $organization->settings ?? []; $settings = $organization->settings ?? [];
$settings['plan'] = $plan; $settings['plan'] = $plan;
$settings['auto_renew'] = false; $settings['auto_renew'] = false;
$settings['billing_method'] = 'paystack_prepaid'; $settings['billing_method'] = 'paystack_prepaid';
$settings['plan_expires_at'] = now()->addMonths($months)->toIso8601String(); $settings['plan_expires_at'] = now()->addMonths($months)->toIso8601String();
if ($enterpriseBranches !== null) { $this->applyBilledBranches($settings, $plan, $billedBranches);
$settings['enterprise_billed_branches'] = $enterpriseBranches;
}
unset($settings['plan_renewal_error']); unset($settings['plan_renewal_error']);
$organization->update(['settings' => $settings]); $organization->update(['settings' => $settings]);
} }
/**
* @param array<string, mixed> $settings
*/
private function applyBilledBranches(array &$settings, string $plan, ?int $billedBranches): void
{
if ($billedBranches === null) {
return;
}
if ($plan === 'enterprise') {
$settings['enterprise_billed_branches'] = $billedBranches;
unset($settings['pro_billed_branches']);
} else {
$settings['pro_billed_branches'] = $billedBranches;
unset($settings['enterprise_billed_branches']);
}
}
} }
+12 -2
View File
@@ -74,9 +74,19 @@ class PlanService
return $max === null || $currentCount < $max; return $max === null || $currentCount < $max;
} }
public function proPriceMinor(): int public function proPricePerBranchMinor(): int
{ {
return (int) config('qms.plans.pro.price_minor', 249000); return (int) config(
'qms.plans.pro.price_minor_per_branch',
config('qms.plans.pro.price_minor', 249000)
);
}
public function proPriceMinor(Organization $organization): int
{
$branches = max(1, $this->activeBranchCount($organization));
return $branches * $this->proPricePerBranchMinor();
} }
public function enterprisePricePerBranchMinor(): int public function enterprisePricePerBranchMinor(): int
+12 -3
View File
@@ -52,9 +52,10 @@ class ProRenewalService
$isEnterprise = $plan === 'enterprise'; $isEnterprise = $plan === 'enterprise';
$price = $isEnterprise $price = $isEnterprise
? $this->plans->enterprisePriceMinor($organization) ? $this->plans->enterprisePriceMinor($organization)
: $this->plans->proPriceMinor(); : $this->plans->proPriceMinor($organization);
$reference = ($isEnterprise ? 'queue-enterprise-' : 'queue-pro-') $reference = ($isEnterprise ? 'queue-enterprise-' : 'queue-pro-')
.$organization->id.'-'.now()->format('YmdHis'); .$organization->id.'-'.now()->format('YmdHis');
$branchCount = $this->plans->activeBranchCount($organization);
try { try {
$charged = $this->billing->debit( $charged = $this->billing->debit(
@@ -77,7 +78,11 @@ class ProRenewalService
->addDays((int) config('qms.pro.period_days', 30)) ->addDays((int) config('qms.pro.period_days', 30))
->toIso8601String(); ->toIso8601String();
if ($isEnterprise) { if ($isEnterprise) {
$settings['enterprise_billed_branches'] = $this->plans->activeBranchCount($organization); $settings['enterprise_billed_branches'] = $branchCount;
unset($settings['pro_billed_branches']);
} else {
$settings['pro_billed_branches'] = $branchCount;
unset($settings['enterprise_billed_branches']);
} }
unset($settings['plan_renewal_error']); unset($settings['plan_renewal_error']);
$organization->update(['settings' => $settings]); $organization->update(['settings' => $settings]);
@@ -89,7 +94,11 @@ class ProRenewalService
$graceEnd = $expires->copy()->addDays((int) config('qms.pro.grace_days', 3)); $graceEnd = $expires->copy()->addDays((int) config('qms.pro.grace_days', 3));
if ($graceEnd->isPast()) { if ($graceEnd->isPast()) {
$settings['plan'] = 'free'; $settings['plan'] = 'free';
unset($settings['plan_expires_at'], $settings['enterprise_billed_branches']); unset(
$settings['plan_expires_at'],
$settings['enterprise_billed_branches'],
$settings['pro_billed_branches'],
);
$settings['plan_renewal_error'] = 'Suspended after failed renewal.'; $settings['plan_renewal_error'] = 'Suspended after failed renewal.';
} else { } else {
$settings['plan_renewal_error'] = 'Renewal failed — top up your wallet.'; $settings['plan_renewal_error'] = 'Renewal failed — top up your wallet.';
+5 -2
View File
@@ -183,7 +183,10 @@ return [
], ],
'pro' => [ 'pro' => [
'label' => 'Pro', 'label' => 'Pro',
'price_minor' => (int) env('QUEUE_PRO_PRICE_MINOR', 249000), 'price_minor_per_branch' => (int) env(
'QUEUE_PRO_PRICE_PER_BRANCH_MINOR',
env('QUEUE_PRO_PRICE_MINOR', 249000)
),
'max_branches' => null, 'max_branches' => null,
'max_queues' => null, 'max_queues' => null,
], ],
@@ -249,7 +252,7 @@ return [
'upgrade_banner' => [ 'upgrade_banner' => [
'title' => 'Unlock Queue Pro or Enterprise', 'title' => 'Unlock Queue Pro or Enterprise',
'description' => 'Unlimited branches, advanced routing, Care & Frontdesk integrations — from GHS 2490/mo.', 'description' => 'Unlimited branches, advanced routing, Care & Frontdesk integrations — from GHS 2490/branch/mo.',
'route' => 'qms.pro.index', 'route' => 'qms.pro.index',
], ],
+25 -5
View File
@@ -1,6 +1,7 @@
<x-app-layout title="Plans" heading="Queue plans"> <x-app-layout title="Plans" heading="Queue plans">
@php @php
$proPrice = number_format($proPriceMinor / 100, 0); $proPerBranch = number_format($proPricePerBranchMinor / 100, 0);
$proTotal = number_format($proPriceMinor / 100, 0);
$enterprisePerBranch = number_format($enterprisePricePerBranchMinor / 100, 0); $enterprisePerBranch = number_format($enterprisePricePerBranchMinor / 100, 0);
$enterpriseTotal = number_format($enterprisePriceMinor / 100, 0); $enterpriseTotal = number_format($enterprisePriceMinor / 100, 0);
@endphp @endphp
@@ -17,6 +18,7 @@
<h1 class="text-2xl font-bold text-slate-900">Choose your Queue plan</h1> <h1 class="text-2xl font-bold text-slate-900">Choose your Queue plan</h1>
<p class="mt-1 text-sm text-slate-600"> <p class="mt-1 text-sm text-slate-600">
Pay monthly from your Ladill wallet, or prepay 6/12/24 months via Paystack. Pay monthly from your Ladill wallet, or prepay 6/12/24 months via Paystack.
Pro and Enterprise are billed per active branch.
Enterprise includes a one-time setup fee <a href="{{ $salesUrl }}" class="font-medium text-indigo-600 hover:text-indigo-800" target="_blank" rel="noopener">contact sales</a> for onboarding. Enterprise includes a one-time setup fee <a href="{{ $salesUrl }}" class="font-medium text-indigo-600 hover:text-indigo-800" target="_blank" rel="noopener">contact sales</a> for onboarding.
</p> </p>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
@@ -50,8 +52,20 @@
{{-- Pro --}} {{-- Pro --}}
<div class="flex flex-col rounded-2xl border border-slate-200 bg-white p-6 {{ $planKey === 'pro' ? 'ring-2 ring-indigo-500' : '' }}"> <div class="flex flex-col rounded-2xl border border-slate-200 bg-white p-6 {{ $planKey === 'pro' ? 'ring-2 ring-indigo-500' : '' }}">
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-600">Pro</p> <p class="text-xs font-semibold uppercase tracking-wide text-indigo-600">Pro</p>
<x-plan-tier-price :currency="$currency" :monthly-minor="$proPriceMinor" /> <x-plan-tier-price
<p class="mt-1 text-sm text-slate-500" x-show="billing === 'monthly'">Unlimited branches & queues</p> :currency="$currency"
:monthly-minor="$proPricePerBranchMinor"
:prepaid-minor="$proPriceMinor"
:monthly-display="$proPerBranch"
monthly-suffix="/branch/mo"
/>
<p class="mt-1 text-sm text-slate-500" x-show="billing === 'monthly'">
@if ($branchCount > 0)
{{ $branchCount }} active {{ str('branch')->plural($branchCount) }} = {{ $currency }} {{ $proTotal }}/mo
@else
Per-branch billing
@endif
</p>
<ul class="mt-5 flex-1 space-y-2 text-sm text-slate-700"> <ul class="mt-5 flex-1 space-y-2 text-sm text-slate-700">
<li>Advanced routing rules</li> <li>Advanced routing rules</li>
<li>Unlimited kiosks & displays</li> <li>Unlimited kiosks & displays</li>
@@ -66,19 +80,25 @@
@if ($planExpiresAt) @if ($planExpiresAt)
until <strong>{{ $planExpiresAt->format('d M Y') }}</strong> until <strong>{{ $planExpiresAt->format('d M Y') }}</strong>
@endif @endif
@if ($proBilledBranches > 0)
· billed for {{ $proBilledBranches }} {{ str('branch')->plural($proBilledBranches) }}
@endif
. .
</p> </p>
@elseif ($canManage) @elseif ($canManage)
<form x-show="billing === 'monthly'" method="post" action="{{ route('qms.pro.subscribe') }}"> <form x-show="billing === 'monthly'" method="post" action="{{ route('qms.pro.subscribe') }}">
@csrf @csrf
<button class="btn-primary w-full">Upgrade to Pro monthly wallet</button> <button class="btn-primary w-full">
Upgrade to Pro {{ $currency }} {{ $proTotal }}/mo wallet
</button>
</form> </form>
<template x-for="months in @js($prepaidMonths)" :key="months"> <template x-for="months in @js($prepaidMonths)" :key="months">
<form x-show="billing == months" method="post" action="{{ route('qms.pro.subscribe-prepaid') }}"> <form x-show="billing == months" method="post" action="{{ route('qms.pro.subscribe-prepaid') }}">
@csrf @csrf
<input type="hidden" name="plan" value="pro"> <input type="hidden" name="plan" value="pro">
<input type="hidden" name="months" :value="months"> <input type="hidden" name="months" :value="months">
<button type="submit" class="btn-primary w-full" x-text="'Pay ' + months + ' months via Paystack'"></button> <button type="submit" class="btn-primary w-full"
x-text="'Pay ' + months + ' months via Paystack — {{ $currency }} ' + ({{ $proPriceMinor }} * months / 100).toLocaleString()"></button>
</form> </form>
</template> </template>
@else @else
+57 -6
View File
@@ -44,12 +44,16 @@ class QmsProTest extends TestCase
public function test_pro_page_renders_three_tier_pricing(): void public function test_pro_page_renders_three_tier_pricing(): void
{ {
$proPerBranch = number_format((int) config('qms.plans.pro.price_minor_per_branch') / 100, 0);
$enterprisePerBranch = number_format((int) config('qms.plans.enterprise.price_minor_per_branch') / 100, 0);
$this->actingAs($this->owner) $this->actingAs($this->owner)
->get(route('qms.pro.index')) ->get(route('qms.pro.index'))
->assertOk() ->assertOk()
->assertSee('Choose your Queue plan') ->assertSee('Choose your Queue plan')
->assertSee('GHS 99') ->assertSee('GHS '.$proPerBranch)
->assertSee('GHS 299') ->assertSee('GHS '.$enterprisePerBranch)
->assertSee('/branch/mo')
->assertSee('Upgrade to Pro') ->assertSee('Upgrade to Pro')
->assertSee('Enterprise'); ->assertSee('Enterprise');
} }
@@ -109,7 +113,9 @@ class QmsProTest extends TestCase
->assertRedirect(route('qms.pro.index')) ->assertRedirect(route('qms.pro.index'))
->assertSessionHas('success'); ->assertSessionHas('success');
$this->assertSame('pro', $this->organization->fresh()->settings['plan']); $settings = $this->organization->fresh()->settings;
$this->assertSame('pro', $settings['plan']);
$this->assertSame(1, $settings['pro_billed_branches']);
} }
public function test_subscribe_enterprise_requires_multiple_branches(): void public function test_subscribe_enterprise_requires_multiple_branches(): void
@@ -147,21 +153,64 @@ class QmsProTest extends TestCase
'settings' => array_merge($this->organization->settings ?? [], [ 'settings' => array_merge($this->organization->settings ?? [], [
'plan' => 'pro', 'plan' => 'pro',
'auto_renew' => true, 'auto_renew' => true,
'pro_billed_branches' => 1,
'plan_expires_at' => now()->subDay()->toIso8601String(), 'plan_expires_at' => now()->subDay()->toIso8601String(),
]), ]),
]); ]);
$expected = (int) config('qms.plans.pro.price_minor_per_branch');
Http::fake([ Http::fake([
'billing.test/debit' => Http::response(['ok' => true]), 'billing.test/debit' => function ($request) use ($expected) {
$this->assertSame($expected, (int) $request['amount_minor']);
return Http::response(['ok' => true]);
},
]); ]);
$this->artisan('qms:pro-renew')->assertSuccessful(); $this->artisan('qms:pro-renew')->assertSuccessful();
$settings = $this->organization->fresh()->settings; $settings = $this->organization->fresh()->settings;
$this->assertSame('pro', $settings['plan']); $this->assertSame('pro', $settings['plan']);
$this->assertSame(1, $settings['pro_billed_branches']);
$this->assertTrue(now()->parse($settings['plan_expires_at'])->isFuture()); $this->assertTrue(now()->parse($settings['plan_expires_at'])->isFuture());
} }
public function test_pro_renew_charges_per_active_branch(): void
{
Branch::create([
'owner_ref' => $this->organization->owner_ref,
'organization_id' => $this->organization->id,
'name' => 'Second',
'is_active' => true,
]);
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'plan' => 'pro',
'auto_renew' => true,
'pro_billed_branches' => 1,
'plan_expires_at' => now()->subDay()->toIso8601String(),
]),
]);
$expected = 2 * (int) config('qms.plans.pro.price_minor_per_branch');
Http::fake([
'billing.test/debit' => function ($request) use ($expected) {
$this->assertSame($expected, (int) $request['amount_minor']);
return Http::response(['ok' => true]);
},
]);
$this->artisan('qms:pro-renew')->assertSuccessful();
$settings = $this->organization->fresh()->settings;
$this->assertSame('pro', $settings['plan']);
$this->assertSame(2, $settings['pro_billed_branches']);
}
public function test_enterprise_renew_charges_per_active_branch(): void public function test_enterprise_renew_charges_per_active_branch(): void
{ {
Branch::create([ Branch::create([
@@ -180,9 +229,11 @@ class QmsProTest extends TestCase
]), ]),
]); ]);
$expected = 2 * (int) config('qms.plans.enterprise.price_minor_per_branch');
Http::fake([ Http::fake([
'billing.test/debit' => function ($request) { 'billing.test/debit' => function ($request) use ($expected) {
$this->assertSame(59800, (int) $request['amount_minor']); $this->assertSame($expected, (int) $request['amount_minor']);
return Http::response(['ok' => true]); return Http::response(['ok' => true]);
}, },