Bill Queue Pro per active branch like Enterprise.
Deploy Ladill Queue / deploy (push) Successful in 33s
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:
@@ -27,7 +27,11 @@ class ProController extends Controller
|
||||
: null;
|
||||
$planKey = $plans->planKey($organization);
|
||||
$branchCount = $plans->activeBranchCount($organization);
|
||||
$proPrice = $plans->proPriceMinor($organization);
|
||||
$enterprisePrice = $plans->enterprisePriceMinor($organization);
|
||||
$billedBranches = (int) ($settings['pro_billed_branches']
|
||||
?? $settings['enterprise_billed_branches']
|
||||
?? $branchCount);
|
||||
|
||||
return view('qms.pro.index', [
|
||||
'organization' => $organization,
|
||||
@@ -36,7 +40,8 @@ class ProController extends Controller
|
||||
'isEnterprise' => $planKey === 'enterprise',
|
||||
'hasPaidPlan' => $plans->hasPaidPlan($organization),
|
||||
'canManage' => $canManage,
|
||||
'proPriceMinor' => $plans->proPriceMinor(),
|
||||
'proPricePerBranchMinor' => $plans->proPricePerBranchMinor(),
|
||||
'proPriceMinor' => $proPrice,
|
||||
'enterprisePricePerBranchMinor' => $plans->enterprisePricePerBranchMinor(),
|
||||
'enterprisePriceMinor' => $enterprisePrice,
|
||||
'branchCount' => $branchCount,
|
||||
@@ -45,7 +50,9 @@ class ProController extends Controller
|
||||
'prepaidMonths' => (array) config('qms.prepaid_months', [6, 12, 24]),
|
||||
'currency' => (string) config('billing.currency', 'GHS'),
|
||||
'planExpiresAt' => $expiresAt,
|
||||
'proBilledBranches' => (int) ($settings['pro_billed_branches'] ?? $branchCount),
|
||||
'enterpriseBilledBranches' => (int) ($settings['enterprise_billed_branches'] ?? $branchCount),
|
||||
'billedBranches' => $billedBranches,
|
||||
'salesUrl' => ladill_platform_url('contact-sales'),
|
||||
]);
|
||||
}
|
||||
@@ -63,12 +70,13 @@ class ProController extends Controller
|
||||
return $this->chargeMonthlyWallet(
|
||||
$organization,
|
||||
$billing,
|
||||
$plans->proPriceMinor(),
|
||||
$plans->proPriceMinor($organization),
|
||||
'pro',
|
||||
'queue_pro',
|
||||
'queue-pro-'.$organization->id.'-'.now()->format('Y-m-d-His'),
|
||||
'Ladill Queue Pro — monthly subscription',
|
||||
'Welcome to Queue Pro — active for one month.',
|
||||
$plans->activeBranchCount($organization),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,9 +136,19 @@ class ProController extends Controller
|
||||
|
||||
$monthlyMinor = $plan === 'enterprise'
|
||||
? $plans->enterprisePriceMinor($organization)
|
||||
: $plans->proPriceMinor();
|
||||
: $plans->proPriceMinor($organization);
|
||||
$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 {
|
||||
$checkout = $billing->initiatePlanCheckout(
|
||||
@@ -139,10 +157,7 @@ class ProController extends Controller
|
||||
$months,
|
||||
$amountMinor,
|
||||
route('qms.pro.paystack.callback'),
|
||||
array_filter([
|
||||
'organization_id' => $organization->id,
|
||||
'enterprise_billed_branches' => $branches,
|
||||
], static fn ($v) => $v !== null),
|
||||
$metadata,
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
return redirect()->route('qms.pro.index')
|
||||
@@ -191,9 +206,13 @@ class ProController extends Controller
|
||||
|
||||
$plan = (string) ($result['plan'] ?? 'pro');
|
||||
$months = (int) ($result['months'] ?? 0);
|
||||
$branches = isset($metadata['enterprise_billed_branches'])
|
||||
? (int) $metadata['enterprise_billed_branches']
|
||||
: null;
|
||||
$branches = isset($metadata['billed_branches'])
|
||||
? (int) $metadata['billed_branches']
|
||||
: (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);
|
||||
|
||||
@@ -212,7 +231,7 @@ class ProController extends Controller
|
||||
string $reference,
|
||||
string $description,
|
||||
string $successMessage,
|
||||
?int $enterpriseBranches = null,
|
||||
?int $billedBranches = null,
|
||||
): RedirectResponse {
|
||||
try {
|
||||
if (! $billing->canAfford($organization->owner_ref, $priceMinor)) {
|
||||
@@ -244,9 +263,7 @@ class ProController extends Controller
|
||||
$settings['plan_expires_at'] = now()
|
||||
->addDays((int) config('qms.pro.period_days', 30))
|
||||
->toIso8601String();
|
||||
if ($enterpriseBranches !== null) {
|
||||
$settings['enterprise_billed_branches'] = $enterpriseBranches;
|
||||
}
|
||||
$this->applyBilledBranches($settings, $plan, $billedBranches);
|
||||
unset($settings['plan_renewal_error']);
|
||||
$organization->update(['settings' => $settings]);
|
||||
|
||||
@@ -257,17 +274,33 @@ class ProController extends Controller
|
||||
Organization $organization,
|
||||
string $plan,
|
||||
int $months,
|
||||
?int $enterpriseBranches = null,
|
||||
?int $billedBranches = 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;
|
||||
}
|
||||
$this->applyBilledBranches($settings, $plan, $billedBranches);
|
||||
unset($settings['plan_renewal_error']);
|
||||
$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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +74,19 @@ class PlanService
|
||||
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
|
||||
|
||||
@@ -52,9 +52,10 @@ class ProRenewalService
|
||||
$isEnterprise = $plan === 'enterprise';
|
||||
$price = $isEnterprise
|
||||
? $this->plans->enterprisePriceMinor($organization)
|
||||
: $this->plans->proPriceMinor();
|
||||
: $this->plans->proPriceMinor($organization);
|
||||
$reference = ($isEnterprise ? 'queue-enterprise-' : 'queue-pro-')
|
||||
.$organization->id.'-'.now()->format('YmdHis');
|
||||
$branchCount = $this->plans->activeBranchCount($organization);
|
||||
|
||||
try {
|
||||
$charged = $this->billing->debit(
|
||||
@@ -77,7 +78,11 @@ class ProRenewalService
|
||||
->addDays((int) config('qms.pro.period_days', 30))
|
||||
->toIso8601String();
|
||||
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']);
|
||||
$organization->update(['settings' => $settings]);
|
||||
@@ -89,7 +94,11 @@ class ProRenewalService
|
||||
$graceEnd = $expires->copy()->addDays((int) config('qms.pro.grace_days', 3));
|
||||
if ($graceEnd->isPast()) {
|
||||
$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.';
|
||||
} else {
|
||||
$settings['plan_renewal_error'] = 'Renewal failed — top up your wallet.';
|
||||
|
||||
+5
-2
@@ -183,7 +183,10 @@ return [
|
||||
],
|
||||
'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_queues' => null,
|
||||
],
|
||||
@@ -249,7 +252,7 @@ return [
|
||||
|
||||
'upgrade_banner' => [
|
||||
'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',
|
||||
],
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<x-app-layout title="Plans" heading="Queue plans">
|
||||
@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);
|
||||
$enterpriseTotal = number_format($enterprisePriceMinor / 100, 0);
|
||||
@endphp
|
||||
@@ -17,6 +18,7 @@
|
||||
<h1 class="text-2xl font-bold text-slate-900">Choose your Queue plan</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">
|
||||
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.
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@@ -50,8 +52,20 @@
|
||||
{{-- Pro --}}
|
||||
<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>
|
||||
<x-plan-tier-price :currency="$currency" :monthly-minor="$proPriceMinor" />
|
||||
<p class="mt-1 text-sm text-slate-500" x-show="billing === 'monthly'">Unlimited branches & queues</p>
|
||||
<x-plan-tier-price
|
||||
: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">
|
||||
<li>Advanced routing rules</li>
|
||||
<li>Unlimited kiosks & displays</li>
|
||||
@@ -66,19 +80,25 @@
|
||||
@if ($planExpiresAt)
|
||||
until <strong>{{ $planExpiresAt->format('d M Y') }}</strong>
|
||||
@endif
|
||||
@if ($proBilledBranches > 0)
|
||||
· billed for {{ $proBilledBranches }} {{ str('branch')->plural($proBilledBranches) }}
|
||||
@endif
|
||||
.
|
||||
</p>
|
||||
@elseif ($canManage)
|
||||
<form x-show="billing === 'monthly'" method="post" action="{{ route('qms.pro.subscribe') }}">
|
||||
@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>
|
||||
<template x-for="months in @js($prepaidMonths)" :key="months">
|
||||
<form x-show="billing == months" method="post" action="{{ route('qms.pro.subscribe-prepaid') }}">
|
||||
@csrf
|
||||
<input type="hidden" name="plan" value="pro">
|
||||
<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>
|
||||
</template>
|
||||
@else
|
||||
|
||||
@@ -44,12 +44,16 @@ class QmsProTest extends TestCase
|
||||
|
||||
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)
|
||||
->get(route('qms.pro.index'))
|
||||
->assertOk()
|
||||
->assertSee('Choose your Queue plan')
|
||||
->assertSee('GHS 99')
|
||||
->assertSee('GHS 299')
|
||||
->assertSee('GHS '.$proPerBranch)
|
||||
->assertSee('GHS '.$enterprisePerBranch)
|
||||
->assertSee('/branch/mo')
|
||||
->assertSee('Upgrade to Pro')
|
||||
->assertSee('Enterprise');
|
||||
}
|
||||
@@ -109,7 +113,9 @@ class QmsProTest extends TestCase
|
||||
->assertRedirect(route('qms.pro.index'))
|
||||
->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
|
||||
@@ -147,21 +153,64 @@ class QmsProTest extends TestCase
|
||||
'settings' => array_merge($this->organization->settings ?? [], [
|
||||
'plan' => 'pro',
|
||||
'auto_renew' => true,
|
||||
'pro_billed_branches' => 1,
|
||||
'plan_expires_at' => now()->subDay()->toIso8601String(),
|
||||
]),
|
||||
]);
|
||||
|
||||
$expected = (int) config('qms.plans.pro.price_minor_per_branch');
|
||||
|
||||
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();
|
||||
|
||||
$settings = $this->organization->fresh()->settings;
|
||||
$this->assertSame('pro', $settings['plan']);
|
||||
$this->assertSame(1, $settings['pro_billed_branches']);
|
||||
$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
|
||||
{
|
||||
Branch::create([
|
||||
@@ -180,9 +229,11 @@ class QmsProTest extends TestCase
|
||||
]),
|
||||
]);
|
||||
|
||||
$expected = 2 * (int) config('qms.plans.enterprise.price_minor_per_branch');
|
||||
|
||||
Http::fake([
|
||||
'billing.test/debit' => function ($request) {
|
||||
$this->assertSame(59800, (int) $request['amount_minor']);
|
||||
'billing.test/debit' => function ($request) use ($expected) {
|
||||
$this->assertSame($expected, (int) $request['amount_minor']);
|
||||
|
||||
return Http::response(['ok' => true]);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user