From b6f229ab9a84437b47f2804c4884e189c93d04ab Mon Sep 17 00:00:00 2001 From: isaacclad Date: Thu, 16 Jul 2026 08:37:06 +0000 Subject: [PATCH] Tighten Pro alert caps and move custom badges to Enterprise. 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. --- .../Controllers/Frontdesk/BadgeController.php | 23 +++++ .../Frontdesk/SettingsController.php | 2 + .../Frontdesk/NotificationBillingService.php | 61 +++++++++++- .../Frontdesk/NotificationDispatcher.php | 4 +- .../Frontdesk/NotificationUsageService.php | 7 +- app/Services/Frontdesk/PlanService.php | 43 ++++++++- config/frontdesk.php | 11 ++- resources/views/frontdesk/pro/index.blade.php | 9 +- .../views/frontdesk/settings/edit.blade.php | 34 +++++-- .../frontdesk/settings/pro-feature.blade.php | 8 +- .../FrontdeskNotificationBillingTest.php | 92 ++++++++++++++++++- tests/Feature/FrontdeskPhase8Test.php | 33 ++++++- 12 files changed, 298 insertions(+), 29 deletions(-) diff --git a/app/Http/Controllers/Frontdesk/BadgeController.php b/app/Http/Controllers/Frontdesk/BadgeController.php index 5df5633..acd0921 100644 --- a/app/Http/Controllers/Frontdesk/BadgeController.php +++ b/app/Http/Controllers/Frontdesk/BadgeController.php @@ -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([ diff --git a/app/Http/Controllers/Frontdesk/SettingsController.php b/app/Http/Controllers/Frontdesk/SettingsController.php index 80cd972..412b918 100644 --- a/app/Http/Controllers/Frontdesk/SettingsController.php +++ b/app/Http/Controllers/Frontdesk/SettingsController.php @@ -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.'), diff --git a/app/Services/Frontdesk/NotificationBillingService.php b/app/Services/Frontdesk/NotificationBillingService.php index da643a1..ac9f63b 100644 --- a/app/Services/Frontdesk/NotificationBillingService.php +++ b/app/Services/Frontdesk/NotificationBillingService.php @@ -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); diff --git a/app/Services/Frontdesk/NotificationDispatcher.php b/app/Services/Frontdesk/NotificationDispatcher.php index 47e50fc..613cb2c 100644 --- a/app/Services/Frontdesk/NotificationDispatcher.php +++ b/app/Services/Frontdesk/NotificationDispatcher.php @@ -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, ]); diff --git a/app/Services/Frontdesk/NotificationUsageService.php b/app/Services/Frontdesk/NotificationUsageService.php index 242a2db..2cd2326 100644 --- a/app/Services/Frontdesk/NotificationUsageService.php +++ b/app/Services/Frontdesk/NotificationUsageService.php @@ -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'); diff --git a/app/Services/Frontdesk/PlanService.php b/app/Services/Frontdesk/PlanService.php index 5bc0b7c..1db0378 100644 --- a/app/Services/Frontdesk/PlanService.php +++ b/app/Services/Frontdesk/PlanService.php @@ -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() diff --git a/config/frontdesk.php b/config/frontdesk.php index b9b2218..ae843a7 100644 --- a/config/frontdesk.php +++ b/config/frontdesk.php @@ -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', diff --git a/resources/views/frontdesk/pro/index.blade.php b/resources/views/frontdesk/pro/index.blade.php index 4b8fa45..e9788f8 100644 --- a/resources/views/frontdesk/pro/index.blade.php +++ b/resources/views/frontdesk/pro/index.blade.php @@ -101,10 +101,10 @@
  • Ladill-provided visitor kiosks
  • Multi-branch locations & buildings
  • Team roles & invitations
  • -
  • Unlimited host email alerts
  • +
  • 5,000 host email alerts / month
  • +
  • 5,000 host SMS alerts / month
  • Webhooks & iCal feeds
  • Scheduled report exports
  • -
  • Custom badge templates
  • Unlimited kiosk software (billed per branch)
  • @@ -119,7 +119,7 @@ @endif .

    -

    SMS host alerts bill at platform rates from your wallet.

    +

    Pro includes 5,000 email and 5,000 SMS host alerts per month. Upgrade to Enterprise for unlimited alerts.

    @if ($canManage)
    @@ -172,7 +172,8 @@

    • Everything in Pro
    • -
    • Ladill-provided visitor kiosks
    • +
    • Unlimited host email & SMS alerts
    • +
    • Custom badge templates
    • Custom integrations
    • Volume SMS rates
    • Dedicated support & SLA
    • diff --git a/resources/views/frontdesk/settings/edit.blade.php b/resources/views/frontdesk/settings/edit.blade.php index 0547dd0..af18580 100644 --- a/resources/views/frontdesk/settings/edit.blade.php +++ b/resources/views/frontdesk/settings/edit.blade.php @@ -8,8 +8,14 @@

      - @if ($hasPaidPlan) - {{ $plan['label'] ?? 'Pro' }} — Ladill-provided visitor kiosks, unlimited kiosk software, integrations, and unlimited host emails, billed per branch (SMS still billed per segment). + @if ($isEnterprise) + Enterprise — unlimited host email & SMS alerts, custom badge templates, integrations, billed per branch. + @if ($billedBranches > 0) + Billed for {{ $billedBranches }} {{ str('branch')->plural($billedBranches) }} + ({{ $activeBranchCount }} active). + @endif + @elseif ($hasPaidPlan) + Pro — 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 @@

      Host emails this month
      @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
      Host SMS this month
      -
      {{ number_format($monthlyUsage->sms_count) }} sent
      +
      + @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 +

      - Email {{ $emailCostFormatted }} each after free allowance · SMS from {{ $smsCostFormatted }} per segment — charged to your - Ladill wallet. + @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 + Ladill wallet. + @endif

      @endif diff --git a/resources/views/frontdesk/settings/pro-feature.blade.php b/resources/views/frontdesk/settings/pro-feature.blade.php index ca5a457..de7b225 100644 --- a/resources/views/frontdesk/settings/pro-feature.blade.php +++ b/resources/views/frontdesk/settings/pro-feature.blade.php @@ -6,13 +6,13 @@ {{ $title }}
    - +

    - 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.

    diff --git a/tests/Feature/FrontdeskNotificationBillingTest.php b/tests/Feature/FrontdeskNotificationBillingTest.php index 7fda0a3..ab26d7e 100644 --- a/tests/Feature/FrontdeskNotificationBillingTest.php +++ b/tests/Feature/FrontdeskNotificationBillingTest.php @@ -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(); diff --git a/tests/Feature/FrontdeskPhase8Test.php b/tests/Feature/FrontdeskPhase8Test.php index 072c408..95a7612 100644 --- a/tests/Feature/FrontdeskPhase8Test.php +++ b/tests/Feature/FrontdeskPhase8Test.php @@ -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());