Tighten Pro alert caps and move custom badges to Enterprise.
Deploy Ladill Frontdesk / deploy (push) Has been cancelled

Pro includes 5,000 email and 5,000 SMS host alerts per month with hard
monthly caps; Enterprise is unlimited. Custom badge templates are
Enterprise-only, and plan benefit copy matches the new limits.
This commit is contained in:
isaacclad
2026-07-16 08:37:06 +00:00
parent 4ce45f4989
commit b6f229ab9a
12 changed files with 298 additions and 29 deletions
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\RequiresPlanFeature;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Visit;
use App\Services\Frontdesk\BadgeRenderService;
@@ -14,11 +15,24 @@ use Illuminate\View\View;
class BadgeController extends Controller
{
use RequiresPlanFeature;
use ScopesToAccount;
private const FEATURE = 'custom_badge_templates';
public function editTemplate(Request $request): View
{
$this->authorizeAbility($request, 'settings.view');
if ($upgrade = $this->proFeatureUpgradeView(
$request,
self::FEATURE,
'Custom badge templates',
'Custom badge layouts and branding are included with Frontdesk Enterprise.',
)) {
return $upgrade;
}
$organization = $this->organization($request);
$canManage = app(FrontdeskPermissions::class)->isAdmin($this->member($request));
@@ -41,6 +55,15 @@ class BadgeController extends Controller
public function updateTemplate(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'settings.manage');
if ($deny = $this->denyWithoutFeature(
$request,
self::FEATURE,
'Custom badge templates require Frontdesk Enterprise.',
)) {
return $deny;
}
$organization = $this->organization($request);
$validated = $request->validate([
@@ -32,6 +32,7 @@ class SettingsController extends Controller
$monthlyUsage = $usage->forOrganization($organization);
$plan = $plans->plan($organization);
$freeEmailAllowance = $plans->freeEmailsPerMonth($organization);
$freeSmsAllowance = $plans->freeSmsPerMonth($organization);
$member = $this->member($request);
$permissions = app(FrontdeskPermissions::class);
@@ -56,6 +57,7 @@ class SettingsController extends Controller
'proPriceMinor' => $plans->proPricePerBranchMinor(),
'monthlyUsage' => $monthlyUsage,
'freeEmailAllowance' => $freeEmailAllowance,
'freeSmsAllowance' => $freeSmsAllowance,
'emailCostFormatted' => $pricing->formatMinor($pricing->emailCostMinor()),
'smsCostFormatted' => $pricing->formatMinor(
$pricing->smsCostMinor('Sample SMS message for pricing display.'),
@@ -29,6 +29,14 @@ class NotificationBillingService
public function emailCostMinor(Organization $organization): int
{
// Paid plan monthly cap exhausted — do not offer overage.
if ($this->plans->emailAllowanceExhausted(
$organization,
$this->usage->emailCountThisMonth($organization),
)) {
return 0;
}
if ($this->usesCustomerEmail($organization)) {
return 0;
}
@@ -41,8 +49,36 @@ class NotificationBillingService
return $this->pricing->emailCostMinor();
}
public function smsCostMinor(Organization $organization, string $message): int
{
if ($this->plans->smsAllowanceExhausted(
$organization,
$this->usage->smsCountThisMonth($organization),
)) {
return 0;
}
if ($this->usesCustomerSms($organization)) {
return 0;
}
$allowance = $this->plans->freeSmsPerMonth($organization);
if ($allowance === null || $this->usage->smsCountThisMonth($organization) < $allowance) {
return 0;
}
return $this->pricing->smsCostMinor($message);
}
public function canAffordEmail(Organization $organization): bool
{
if ($this->plans->emailAllowanceExhausted(
$organization,
$this->usage->emailCountThisMonth($organization),
)) {
return false;
}
if ($this->usesCustomerEmail($organization)) {
return true;
}
@@ -57,11 +93,18 @@ class NotificationBillingService
public function canAffordSms(Organization $organization, string $message): bool
{
if ($this->plans->smsAllowanceExhausted(
$organization,
$this->usage->smsCountThisMonth($organization),
)) {
return false;
}
if ($this->usesCustomerSms($organization)) {
return true;
}
$cost = $this->pricing->smsCostMinor($message);
$cost = $this->smsCostMinor($organization, $message);
if ($cost <= 0) {
return true;
}
@@ -75,6 +118,13 @@ class NotificationBillingService
*/
public function chargeEmail(Organization $organization, string $reference, string $description): bool
{
if ($this->plans->emailAllowanceExhausted(
$organization,
$this->usage->emailCountThisMonth($organization),
)) {
return false;
}
if ($this->usesCustomerEmail($organization)) {
$this->usage->recordEmail($organization, 0);
@@ -99,13 +149,20 @@ class NotificationBillingService
public function chargeSms(Organization $organization, string $message, string $reference, string $description): bool
{
if ($this->plans->smsAllowanceExhausted(
$organization,
$this->usage->smsCountThisMonth($organization),
)) {
return false;
}
if ($this->usesCustomerSms($organization)) {
$this->usage->recordSms($organization, 0);
return true;
}
$cost = $this->pricing->smsCostMinor($message);
$cost = $this->smsCostMinor($organization, $message);
if ($cost <= 0) {
$this->usage->recordSms($organization, 0);
@@ -213,7 +213,7 @@ class NotificationDispatcher
'organization_id' => $organization->id,
]);
} elseif (! $this->billing->canAffordEmail($organization)) {
Log::info('Frontdesk host email skipped — insufficient wallet balance', [
Log::info('Frontdesk host email skipped — plan allowance exhausted or insufficient wallet balance', [
'host_id' => $host->id,
'organization_id' => $organization->id,
]);
@@ -261,7 +261,7 @@ class NotificationDispatcher
'organization_id' => $organization->id,
]);
} elseif (! $this->billing->canAffordSms($organization, $smsBody)) {
Log::info('Frontdesk host SMS skipped — insufficient wallet balance', [
Log::info('Frontdesk host SMS skipped — plan allowance exhausted or insufficient wallet balance', [
'host_id' => $host->id,
'organization_id' => $organization->id,
]);
@@ -27,6 +27,11 @@ class NotificationUsageService
return (int) $this->forOrganization($organization)->email_count;
}
public function smsCountThisMonth(Organization $organization): int
{
return (int) $this->forOrganization($organization)->sms_count;
}
public function recordEmail(Organization $organization, int $chargedMinor = 0): void
{
$usage = $this->forOrganization($organization);
@@ -36,7 +41,7 @@ class NotificationUsageService
}
}
public function recordSms(Organization $organization, int $chargedMinor): void
public function recordSms(Organization $organization, int $chargedMinor = 0): void
{
$usage = $this->forOrganization($organization);
$usage->increment('sms_count');
+42 -1
View File
@@ -49,7 +49,10 @@ class PlanService
];
}
/** Null means unlimited included host emails (Pro / Enterprise). */
/**
* Included host emails per calendar month.
* Null means unlimited (Enterprise). Free/Pro use finite allowances.
*/
public function freeEmailsPerMonth(Organization $organization): ?int
{
$value = config('frontdesk.plans.'.$this->planKey($organization).'.free_emails_per_month', 100);
@@ -57,6 +60,44 @@ class PlanService
return $value === null ? null : (int) $value;
}
/**
* Included host SMS per calendar month.
* Null means unlimited (Enterprise). 0 means none included.
*/
public function freeSmsPerMonth(Organization $organization): ?int
{
$plan = (array) config('frontdesk.plans.'.$this->planKey($organization), []);
if (! array_key_exists('free_sms_per_month', $plan)) {
return 0;
}
$value = $plan['free_sms_per_month'];
return $value === null ? null : (int) $value;
}
/**
* Paid plans with a finite allowance stop sending once the monthly cap is hit.
* Free keeps pay-as-you-go after its free email allowance.
*/
public function emailAllowanceExhausted(Organization $organization, int $sentThisMonth): bool
{
$allowance = $this->freeEmailsPerMonth($organization);
return $this->hasPaidPlan($organization)
&& $allowance !== null
&& $sentThisMonth >= $allowance;
}
public function smsAllowanceExhausted(Organization $organization, int $sentThisMonth): bool
{
$allowance = $this->freeSmsPerMonth($organization);
return $this->hasPaidPlan($organization)
&& $allowance !== null
&& $sentThisMonth >= $allowance;
}
public function activeBranchCount(Organization $organization): int
{
return Branch::query()
+7 -4
View File
@@ -132,6 +132,8 @@ return [
'max_branches' => 1,
'max_kiosk_devices' => 1,
'free_emails_per_month' => (int) env('FRONTDESK_FREE_EMAILS_PER_MONTH', 100),
// Free has no included SMS — billed per segment (or customer SMS key).
'free_sms_per_month' => (int) env('FRONTDESK_FREE_SMS_PER_MONTH', 0),
'features' => [
'check_in',
'hosts',
@@ -152,9 +154,9 @@ return [
),
'max_branches' => null,
'max_kiosk_devices' => null,
'free_emails_per_month' => env('FRONTDESK_PRO_FREE_EMAILS_PER_MONTH') !== null
? (int) env('FRONTDESK_PRO_FREE_EMAILS_PER_MONTH')
: null,
// 5,000 email + 5,000 SMS host alerts / month (10,000 combined).
'free_emails_per_month' => (int) env('FRONTDESK_PRO_FREE_EMAILS_PER_MONTH', 5000),
'free_sms_per_month' => (int) env('FRONTDESK_PRO_FREE_SMS_PER_MONTH', 5000),
'features' => [
'check_in',
'hosts',
@@ -165,7 +167,6 @@ return [
'integrations',
'webhooks',
'scheduled_reports',
'custom_badge_templates',
],
],
'enterprise' => [
@@ -174,7 +175,9 @@ return [
'price_minor_per_branch' => (int) env('FRONTDESK_ENTERPRISE_PRICE_PER_BRANCH_MINOR', 199000),
'max_branches' => null,
'max_kiosk_devices' => null,
// null = unlimited host email / SMS alerts.
'free_emails_per_month' => null,
'free_sms_per_month' => null,
'features' => [
'check_in',
'hosts',
@@ -101,10 +101,10 @@
<li>Ladill-provided visitor kiosks</li>
<li>Multi-branch locations & buildings</li>
<li>Team roles & invitations</li>
<li>Unlimited host email alerts</li>
<li>5,000 host email alerts / month</li>
<li>5,000 host SMS alerts / month</li>
<li>Webhooks & iCal feeds</li>
<li>Scheduled report exports</li>
<li>Custom badge templates</li>
<li>Unlimited kiosk software (billed per branch)</li>
</ul>
<div class="mt-6">
@@ -119,7 +119,7 @@
@endif
.
</p>
<p class="mt-2 text-xs text-slate-400">SMS host alerts bill at platform rates from your wallet.</p>
<p class="mt-2 text-xs text-slate-400">Pro includes 5,000 email and 5,000 SMS host alerts per month. Upgrade to Enterprise for unlimited alerts.</p>
@if ($canManage)
<form method="post" action="{{ route('frontdesk.pro.expand-branches') }}" class="mt-4 space-y-2"
x-data="{ seats: {{ (int) $billedBranches + 1 }}, rate: {{ (int) $proPricePerBranchMinor }}, current: {{ (int) $billedBranches }} }">
@@ -172,7 +172,8 @@
</p>
<ul class="mt-5 flex-1 space-y-2 text-sm text-slate-200">
<li>Everything in Pro</li>
<li>Ladill-provided visitor kiosks</li>
<li>Unlimited host email & SMS alerts</li>
<li>Custom badge templates</li>
<li>Custom integrations</li>
<li>Volume SMS rates</li>
<li>Dedicated support & SLA</li>
@@ -8,8 +8,14 @@
<x-settings.card title="Plan & usage" description="Subscription limits and notification billing for {{ $organization->name }}.">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<p class="text-sm leading-6 text-slate-600">
@if ($hasPaidPlan)
<span class="font-medium text-indigo-700">{{ $plan['label'] ?? 'Pro' }}</span> Ladill-provided visitor kiosks, unlimited kiosk software, integrations, and unlimited host emails, billed per branch (SMS still billed per segment).
@if ($isEnterprise)
<span class="font-medium text-indigo-700">Enterprise</span> unlimited host email &amp; SMS alerts, custom badge templates, integrations, billed per branch.
@if ($billedBranches > 0)
Billed for {{ $billedBranches }} {{ str('branch')->plural($billedBranches) }}
({{ $activeBranchCount }} active).
@endif
@elseif ($hasPaidPlan)
<span class="font-medium text-indigo-700">Pro</span> Ladill-provided visitor kiosks, integrations, and 5,000 email + 5,000 SMS host alerts / month, billed per branch.
@if ($billedBranches > 0)
Billed for {{ $billedBranches }} {{ str('branch')->plural($billedBranches) }}
({{ $activeBranchCount }} active).
@@ -29,20 +35,34 @@
<dt class="text-slate-500">Host emails this month</dt>
<dd class="mt-0.5 font-semibold text-slate-900">
@if ($freeEmailAllowance === null)
{{ number_format($monthlyUsage->email_count) }} sent (unlimited on paid plans)
{{ number_format($monthlyUsage->email_count) }} sent (unlimited)
@else
{{ number_format($monthlyUsage->email_count) }} / {{ number_format($freeEmailAllowance) }} free
{{ number_format($monthlyUsage->email_count) }} / {{ number_format($freeEmailAllowance) }} included
@endif
</dd>
</div>
<div>
<dt class="text-slate-500">Host SMS this month</dt>
<dd class="mt-0.5 font-semibold text-slate-900">{{ number_format($monthlyUsage->sms_count) }} sent</dd>
<dd class="mt-0.5 font-semibold text-slate-900">
@if ($freeSmsAllowance === null)
{{ number_format($monthlyUsage->sms_count) }} sent (unlimited)
@elseif ($freeSmsAllowance > 0)
{{ number_format($monthlyUsage->sms_count) }} / {{ number_format($freeSmsAllowance) }} included
@else
{{ number_format($monthlyUsage->sms_count) }} sent
@endif
</dd>
</div>
</dl>
<p class="mt-4 text-xs leading-5 text-slate-500">
Email {{ $emailCostFormatted }} each after free allowance · SMS from {{ $smsCostFormatted }} per segment charged to your
<a href="{{ route('frontdesk.wallet') }}" class="font-medium text-indigo-600 underline">Ladill wallet</a>.
@if ($isEnterprise)
Host email and SMS alerts are unlimited on Enterprise.
@elseif ($hasPaidPlan)
Pro includes 5,000 email and 5,000 SMS alerts per month. Upgrade to Enterprise for unlimited alerts and custom badge templates.
@else
Email {{ $emailCostFormatted }} each after free allowance · SMS from {{ $smsCostFormatted }} per segment charged to your
<a href="{{ route('frontdesk.wallet') }}" class="font-medium text-indigo-600 underline">Ladill wallet</a>.
@endif
</p>
</x-settings.card>
@endif
@@ -6,13 +6,13 @@
<span class="font-medium text-slate-900">{{ $title }}</span>
</div>
<x-settings.card title="Upgrade to Pro">
<x-settings.card title="Upgrade your plan">
<p class="text-sm text-slate-600">
GHS {{ number_format($proPriceMinor / 100, 0) }} / branch / month from your Ladill wallet
provided visitor kiosks, multi-branch locations, team roles, integrations, and more.
GHS {{ number_format($proPriceMinor / 100, 0) }} / branch / month for Pro from your Ladill wallet
provided visitor kiosks, multi-branch locations, team roles, and integrations. Enterprise adds unlimited alerts and custom badge templates.
</p>
<div class="mt-4 flex flex-wrap items-center gap-3">
<a href="{{ route('frontdesk.pro.index') }}" class="btn-primary">View Pro plans</a>
<a href="{{ route('frontdesk.pro.index') }}" class="btn-primary">View plans</a>
<a href="{{ route('frontdesk.wallet') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">Top up wallet</a>
</div>
</x-settings.card>
@@ -242,7 +242,7 @@ class FrontdeskNotificationBillingTest extends TestCase
->assertSee('Upgrade to Pro');
}
public function test_pro_plan_emails_record_usage_without_wallet_debit(): void
public function test_pro_plan_emails_within_allowance_skip_wallet_debit(): void
{
Http::fake([
'billing.test/can-afford*' => Http::response(['affordable' => true]),
@@ -260,7 +260,7 @@ class FrontdeskNotificationBillingTest extends TestCase
NotificationUsage::create([
'organization_id' => $this->organization->id,
'period' => now()->format('Y-m'),
'email_count' => 10_000,
'email_count' => 4_999,
]);
$host = Host::create([
@@ -284,10 +284,96 @@ class FrontdeskNotificationBillingTest extends TestCase
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/debit'));
$usage = NotificationUsage::where('organization_id', $this->organization->id)->first();
$this->assertSame(10_001, $usage->email_count);
$this->assertSame(5_000, $usage->email_count);
$this->assertSame(0, $usage->email_charged_minor);
}
public function test_pro_plan_stops_email_after_monthly_allowance(): void
{
Http::fake([
'billing.test/can-afford*' => Http::response(['affordable' => true]),
'billing.test/debit' => Http::response(['ok' => true]),
'*/api/smtp/send' => Http::response(['success' => true]),
]);
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'plan' => 'pro',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
]),
]);
NotificationUsage::create([
'organization_id' => $this->organization->id,
'period' => now()->format('Y-m'),
'email_count' => 5_000,
]);
$host = Host::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'name' => 'Capped Host',
'email' => 'capped@example.com',
'is_available' => true,
]);
$this->actingAs($this->owner)
->post(route('frontdesk.visits.store'), [
'full_name' => 'Capped Guest',
'host_id' => $host->id,
'visitor_type' => 'visitor',
'policies_accepted' => '1',
])
->assertRedirect();
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/api/smtp/send'));
$usage = NotificationUsage::where('organization_id', $this->organization->id)->first();
$this->assertSame(5_000, $usage->email_count);
}
public function test_enterprise_plan_emails_are_unlimited(): void
{
Http::fake([
'billing.test/can-afford*' => Http::response(['affordable' => true]),
'billing.test/debit' => Http::response(['ok' => true]),
'*/api/smtp/send' => Http::response(['success' => true]),
]);
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'plan' => 'enterprise',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
]),
]);
NotificationUsage::create([
'organization_id' => $this->organization->id,
'period' => now()->format('Y-m'),
'email_count' => 50_000,
]);
$host = Host::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'name' => 'Ent Host',
'email' => 'ent@example.com',
'is_available' => true,
]);
$this->actingAs($this->owner)
->post(route('frontdesk.visits.store'), [
'full_name' => 'Ent Guest',
'host_id' => $host->id,
'visitor_type' => 'visitor',
'policies_accepted' => '1',
])
->assertRedirect();
Http::assertSent(fn ($request) => str_contains($request->url(), '/api/smtp/send'));
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/debit'));
}
public function test_linked_user_still_gets_in_app_notification(): void
{
Notification::fake();
+32 -1
View File
@@ -61,8 +61,15 @@ class FrontdeskPhase8Test extends TestCase
]);
}
public function test_badge_template_settings_can_be_updated(): void
public function test_badge_template_settings_can_be_updated_on_enterprise(): void
{
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'plan' => 'enterprise',
'plan_expires_at' => now()->addYear()->toIso8601String(),
]),
]);
$this->actingAs($this->user)
->put(route('frontdesk.settings.badge.update'), [
'show_photo' => '1',
@@ -80,6 +87,30 @@ class FrontdeskPhase8Test extends TestCase
$this->assertFalse($settings['badge_template']['show_company']);
}
public function test_badge_template_requires_enterprise(): void
{
$this->organization->update([
'settings' => array_merge($this->organization->settings ?? [], [
'plan' => 'pro',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
]),
]);
$this->actingAs($this->user)
->get(route('frontdesk.settings.badge'))
->assertOk()
->assertSee('Upgrade your plan');
$this->actingAs($this->user)
->put(route('frontdesk.settings.badge.update'), [
'show_photo' => '1',
'show_qr' => '1',
'primary_color' => '#00ff00',
])
->assertRedirect(route('frontdesk.pro.index'))
->assertSessionHas('error');
}
public function test_check_in_stores_photo_from_base64(): void
{
$png = 'data:image/png;base64,'.base64_encode(UploadedFile::fake()->image('photo.png')->getContent());