Tighten Pro alert caps and move custom badges to Enterprise.
Deploy Ladill Frontdesk / deploy (push) Has been cancelled
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:
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Frontdesk;
|
namespace App\Http\Controllers\Frontdesk;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\Frontdesk\Concerns\RequiresPlanFeature;
|
||||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||||
use App\Models\Visit;
|
use App\Models\Visit;
|
||||||
use App\Services\Frontdesk\BadgeRenderService;
|
use App\Services\Frontdesk\BadgeRenderService;
|
||||||
@@ -14,11 +15,24 @@ use Illuminate\View\View;
|
|||||||
|
|
||||||
class BadgeController extends Controller
|
class BadgeController extends Controller
|
||||||
{
|
{
|
||||||
|
use RequiresPlanFeature;
|
||||||
use ScopesToAccount;
|
use ScopesToAccount;
|
||||||
|
|
||||||
|
private const FEATURE = 'custom_badge_templates';
|
||||||
|
|
||||||
public function editTemplate(Request $request): View
|
public function editTemplate(Request $request): View
|
||||||
{
|
{
|
||||||
$this->authorizeAbility($request, 'settings.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);
|
$organization = $this->organization($request);
|
||||||
$canManage = app(FrontdeskPermissions::class)->isAdmin($this->member($request));
|
$canManage = app(FrontdeskPermissions::class)->isAdmin($this->member($request));
|
||||||
|
|
||||||
@@ -41,6 +55,15 @@ class BadgeController extends Controller
|
|||||||
public function updateTemplate(Request $request): RedirectResponse
|
public function updateTemplate(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->authorizeAbility($request, 'settings.manage');
|
$this->authorizeAbility($request, 'settings.manage');
|
||||||
|
|
||||||
|
if ($deny = $this->denyWithoutFeature(
|
||||||
|
$request,
|
||||||
|
self::FEATURE,
|
||||||
|
'Custom badge templates require Frontdesk Enterprise.',
|
||||||
|
)) {
|
||||||
|
return $deny;
|
||||||
|
}
|
||||||
|
|
||||||
$organization = $this->organization($request);
|
$organization = $this->organization($request);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class SettingsController extends Controller
|
|||||||
$monthlyUsage = $usage->forOrganization($organization);
|
$monthlyUsage = $usage->forOrganization($organization);
|
||||||
$plan = $plans->plan($organization);
|
$plan = $plans->plan($organization);
|
||||||
$freeEmailAllowance = $plans->freeEmailsPerMonth($organization);
|
$freeEmailAllowance = $plans->freeEmailsPerMonth($organization);
|
||||||
|
$freeSmsAllowance = $plans->freeSmsPerMonth($organization);
|
||||||
$member = $this->member($request);
|
$member = $this->member($request);
|
||||||
$permissions = app(FrontdeskPermissions::class);
|
$permissions = app(FrontdeskPermissions::class);
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ class SettingsController extends Controller
|
|||||||
'proPriceMinor' => $plans->proPricePerBranchMinor(),
|
'proPriceMinor' => $plans->proPricePerBranchMinor(),
|
||||||
'monthlyUsage' => $monthlyUsage,
|
'monthlyUsage' => $monthlyUsage,
|
||||||
'freeEmailAllowance' => $freeEmailAllowance,
|
'freeEmailAllowance' => $freeEmailAllowance,
|
||||||
|
'freeSmsAllowance' => $freeSmsAllowance,
|
||||||
'emailCostFormatted' => $pricing->formatMinor($pricing->emailCostMinor()),
|
'emailCostFormatted' => $pricing->formatMinor($pricing->emailCostMinor()),
|
||||||
'smsCostFormatted' => $pricing->formatMinor(
|
'smsCostFormatted' => $pricing->formatMinor(
|
||||||
$pricing->smsCostMinor('Sample SMS message for pricing display.'),
|
$pricing->smsCostMinor('Sample SMS message for pricing display.'),
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ class NotificationBillingService
|
|||||||
|
|
||||||
public function emailCostMinor(Organization $organization): int
|
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)) {
|
if ($this->usesCustomerEmail($organization)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -41,8 +49,36 @@ class NotificationBillingService
|
|||||||
return $this->pricing->emailCostMinor();
|
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
|
public function canAffordEmail(Organization $organization): bool
|
||||||
{
|
{
|
||||||
|
if ($this->plans->emailAllowanceExhausted(
|
||||||
|
$organization,
|
||||||
|
$this->usage->emailCountThisMonth($organization),
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->usesCustomerEmail($organization)) {
|
if ($this->usesCustomerEmail($organization)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -57,11 +93,18 @@ class NotificationBillingService
|
|||||||
|
|
||||||
public function canAffordSms(Organization $organization, string $message): bool
|
public function canAffordSms(Organization $organization, string $message): bool
|
||||||
{
|
{
|
||||||
|
if ($this->plans->smsAllowanceExhausted(
|
||||||
|
$organization,
|
||||||
|
$this->usage->smsCountThisMonth($organization),
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->usesCustomerSms($organization)) {
|
if ($this->usesCustomerSms($organization)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cost = $this->pricing->smsCostMinor($message);
|
$cost = $this->smsCostMinor($organization, $message);
|
||||||
if ($cost <= 0) {
|
if ($cost <= 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -75,6 +118,13 @@ class NotificationBillingService
|
|||||||
*/
|
*/
|
||||||
public function chargeEmail(Organization $organization, string $reference, string $description): bool
|
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)) {
|
if ($this->usesCustomerEmail($organization)) {
|
||||||
$this->usage->recordEmail($organization, 0);
|
$this->usage->recordEmail($organization, 0);
|
||||||
|
|
||||||
@@ -99,13 +149,20 @@ class NotificationBillingService
|
|||||||
|
|
||||||
public function chargeSms(Organization $organization, string $message, string $reference, string $description): bool
|
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)) {
|
if ($this->usesCustomerSms($organization)) {
|
||||||
$this->usage->recordSms($organization, 0);
|
$this->usage->recordSms($organization, 0);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cost = $this->pricing->smsCostMinor($message);
|
$cost = $this->smsCostMinor($organization, $message);
|
||||||
if ($cost <= 0) {
|
if ($cost <= 0) {
|
||||||
$this->usage->recordSms($organization, 0);
|
$this->usage->recordSms($organization, 0);
|
||||||
|
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ class NotificationDispatcher
|
|||||||
'organization_id' => $organization->id,
|
'organization_id' => $organization->id,
|
||||||
]);
|
]);
|
||||||
} elseif (! $this->billing->canAffordEmail($organization)) {
|
} 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,
|
'host_id' => $host->id,
|
||||||
'organization_id' => $organization->id,
|
'organization_id' => $organization->id,
|
||||||
]);
|
]);
|
||||||
@@ -261,7 +261,7 @@ class NotificationDispatcher
|
|||||||
'organization_id' => $organization->id,
|
'organization_id' => $organization->id,
|
||||||
]);
|
]);
|
||||||
} elseif (! $this->billing->canAffordSms($organization, $smsBody)) {
|
} 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,
|
'host_id' => $host->id,
|
||||||
'organization_id' => $organization->id,
|
'organization_id' => $organization->id,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ class NotificationUsageService
|
|||||||
return (int) $this->forOrganization($organization)->email_count;
|
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
|
public function recordEmail(Organization $organization, int $chargedMinor = 0): void
|
||||||
{
|
{
|
||||||
$usage = $this->forOrganization($organization);
|
$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 = $this->forOrganization($organization);
|
||||||
$usage->increment('sms_count');
|
$usage->increment('sms_count');
|
||||||
|
|||||||
@@ -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
|
public function freeEmailsPerMonth(Organization $organization): ?int
|
||||||
{
|
{
|
||||||
$value = config('frontdesk.plans.'.$this->planKey($organization).'.free_emails_per_month', 100);
|
$value = config('frontdesk.plans.'.$this->planKey($organization).'.free_emails_per_month', 100);
|
||||||
@@ -57,6 +60,44 @@ class PlanService
|
|||||||
return $value === null ? null : (int) $value;
|
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
|
public function activeBranchCount(Organization $organization): int
|
||||||
{
|
{
|
||||||
return Branch::query()
|
return Branch::query()
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ return [
|
|||||||
'max_branches' => 1,
|
'max_branches' => 1,
|
||||||
'max_kiosk_devices' => 1,
|
'max_kiosk_devices' => 1,
|
||||||
'free_emails_per_month' => (int) env('FRONTDESK_FREE_EMAILS_PER_MONTH', 100),
|
'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' => [
|
'features' => [
|
||||||
'check_in',
|
'check_in',
|
||||||
'hosts',
|
'hosts',
|
||||||
@@ -152,9 +154,9 @@ return [
|
|||||||
),
|
),
|
||||||
'max_branches' => null,
|
'max_branches' => null,
|
||||||
'max_kiosk_devices' => null,
|
'max_kiosk_devices' => null,
|
||||||
'free_emails_per_month' => env('FRONTDESK_PRO_FREE_EMAILS_PER_MONTH') !== null
|
// 5,000 email + 5,000 SMS host alerts / month (10,000 combined).
|
||||||
? (int) env('FRONTDESK_PRO_FREE_EMAILS_PER_MONTH')
|
'free_emails_per_month' => (int) env('FRONTDESK_PRO_FREE_EMAILS_PER_MONTH', 5000),
|
||||||
: null,
|
'free_sms_per_month' => (int) env('FRONTDESK_PRO_FREE_SMS_PER_MONTH', 5000),
|
||||||
'features' => [
|
'features' => [
|
||||||
'check_in',
|
'check_in',
|
||||||
'hosts',
|
'hosts',
|
||||||
@@ -165,7 +167,6 @@ return [
|
|||||||
'integrations',
|
'integrations',
|
||||||
'webhooks',
|
'webhooks',
|
||||||
'scheduled_reports',
|
'scheduled_reports',
|
||||||
'custom_badge_templates',
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'enterprise' => [
|
'enterprise' => [
|
||||||
@@ -174,7 +175,9 @@ return [
|
|||||||
'price_minor_per_branch' => (int) env('FRONTDESK_ENTERPRISE_PRICE_PER_BRANCH_MINOR', 199000),
|
'price_minor_per_branch' => (int) env('FRONTDESK_ENTERPRISE_PRICE_PER_BRANCH_MINOR', 199000),
|
||||||
'max_branches' => null,
|
'max_branches' => null,
|
||||||
'max_kiosk_devices' => null,
|
'max_kiosk_devices' => null,
|
||||||
|
// null = unlimited host email / SMS alerts.
|
||||||
'free_emails_per_month' => null,
|
'free_emails_per_month' => null,
|
||||||
|
'free_sms_per_month' => null,
|
||||||
'features' => [
|
'features' => [
|
||||||
'check_in',
|
'check_in',
|
||||||
'hosts',
|
'hosts',
|
||||||
|
|||||||
@@ -101,10 +101,10 @@
|
|||||||
<li>Ladill-provided visitor kiosks</li>
|
<li>Ladill-provided visitor kiosks</li>
|
||||||
<li>Multi-branch locations & buildings</li>
|
<li>Multi-branch locations & buildings</li>
|
||||||
<li>Team roles & invitations</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>Webhooks & iCal feeds</li>
|
||||||
<li>Scheduled report exports</li>
|
<li>Scheduled report exports</li>
|
||||||
<li>Custom badge templates</li>
|
|
||||||
<li>Unlimited kiosk software (billed per branch)</li>
|
<li>Unlimited kiosk software (billed per branch)</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
@endif
|
@endif
|
||||||
.
|
.
|
||||||
</p>
|
</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)
|
@if ($canManage)
|
||||||
<form method="post" action="{{ route('frontdesk.pro.expand-branches') }}" class="mt-4 space-y-2"
|
<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 }} }">
|
x-data="{ seats: {{ (int) $billedBranches + 1 }}, rate: {{ (int) $proPricePerBranchMinor }}, current: {{ (int) $billedBranches }} }">
|
||||||
@@ -172,7 +172,8 @@
|
|||||||
</p>
|
</p>
|
||||||
<ul class="mt-5 flex-1 space-y-2 text-sm text-slate-200">
|
<ul class="mt-5 flex-1 space-y-2 text-sm text-slate-200">
|
||||||
<li>Everything in Pro</li>
|
<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>Custom integrations</li>
|
||||||
<li>Volume SMS rates</li>
|
<li>Volume SMS rates</li>
|
||||||
<li>Dedicated support & SLA</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 }}.">
|
<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">
|
<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">
|
<p class="text-sm leading-6 text-slate-600">
|
||||||
@if ($hasPaidPlan)
|
@if ($isEnterprise)
|
||||||
<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).
|
<span class="font-medium text-indigo-700">Enterprise</span> — 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)
|
||||||
|
<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)
|
@if ($billedBranches > 0)
|
||||||
Billed for {{ $billedBranches }} {{ str('branch')->plural($billedBranches) }}
|
Billed for {{ $billedBranches }} {{ str('branch')->plural($billedBranches) }}
|
||||||
({{ $activeBranchCount }} active).
|
({{ $activeBranchCount }} active).
|
||||||
@@ -29,20 +35,34 @@
|
|||||||
<dt class="text-slate-500">Host emails this month</dt>
|
<dt class="text-slate-500">Host emails this month</dt>
|
||||||
<dd class="mt-0.5 font-semibold text-slate-900">
|
<dd class="mt-0.5 font-semibold text-slate-900">
|
||||||
@if ($freeEmailAllowance === null)
|
@if ($freeEmailAllowance === null)
|
||||||
{{ number_format($monthlyUsage->email_count) }} sent (unlimited on paid plans)
|
{{ number_format($monthlyUsage->email_count) }} sent (unlimited)
|
||||||
@else
|
@else
|
||||||
{{ number_format($monthlyUsage->email_count) }} / {{ number_format($freeEmailAllowance) }} free
|
{{ number_format($monthlyUsage->email_count) }} / {{ number_format($freeEmailAllowance) }} included
|
||||||
@endif
|
@endif
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-slate-500">Host SMS this month</dt>
|
<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>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<p class="mt-4 text-xs leading-5 text-slate-500">
|
<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
|
@if ($isEnterprise)
|
||||||
<a href="{{ route('frontdesk.wallet') }}" class="font-medium text-indigo-600 underline">Ladill wallet</a>.
|
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>
|
</p>
|
||||||
</x-settings.card>
|
</x-settings.card>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
<span class="font-medium text-slate-900">{{ $title }}</span>
|
<span class="font-medium text-slate-900">{{ $title }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-settings.card title="Upgrade to Pro">
|
<x-settings.card title="Upgrade your plan">
|
||||||
<p class="text-sm text-slate-600">
|
<p class="text-sm text-slate-600">
|
||||||
GHS {{ number_format($proPriceMinor / 100, 0) }} / branch / month from your Ladill wallet —
|
GHS {{ number_format($proPriceMinor / 100, 0) }} / branch / month for Pro from your Ladill wallet —
|
||||||
provided visitor kiosks, multi-branch locations, team roles, integrations, and more.
|
provided visitor kiosks, multi-branch locations, team roles, and integrations. Enterprise adds unlimited alerts and custom badge templates.
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
<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>
|
<a href="{{ route('frontdesk.wallet') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">Top up wallet</a>
|
||||||
</div>
|
</div>
|
||||||
</x-settings.card>
|
</x-settings.card>
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ class FrontdeskNotificationBillingTest extends TestCase
|
|||||||
->assertSee('Upgrade to Pro');
|
->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([
|
Http::fake([
|
||||||
'billing.test/can-afford*' => Http::response(['affordable' => true]),
|
'billing.test/can-afford*' => Http::response(['affordable' => true]),
|
||||||
@@ -260,7 +260,7 @@ class FrontdeskNotificationBillingTest extends TestCase
|
|||||||
NotificationUsage::create([
|
NotificationUsage::create([
|
||||||
'organization_id' => $this->organization->id,
|
'organization_id' => $this->organization->id,
|
||||||
'period' => now()->format('Y-m'),
|
'period' => now()->format('Y-m'),
|
||||||
'email_count' => 10_000,
|
'email_count' => 4_999,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$host = Host::create([
|
$host = Host::create([
|
||||||
@@ -284,10 +284,96 @@ class FrontdeskNotificationBillingTest extends TestCase
|
|||||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/debit'));
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/debit'));
|
||||||
|
|
||||||
$usage = NotificationUsage::where('organization_id', $this->organization->id)->first();
|
$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);
|
$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
|
public function test_linked_user_still_gets_in_app_notification(): void
|
||||||
{
|
{
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
|
|||||||
@@ -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)
|
$this->actingAs($this->user)
|
||||||
->put(route('frontdesk.settings.badge.update'), [
|
->put(route('frontdesk.settings.badge.update'), [
|
||||||
'show_photo' => '1',
|
'show_photo' => '1',
|
||||||
@@ -80,6 +87,30 @@ class FrontdeskPhase8Test extends TestCase
|
|||||||
$this->assertFalse($settings['badge_template']['show_company']);
|
$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
|
public function test_check_in_stores_photo_from_base64(): void
|
||||||
{
|
{
|
||||||
$png = 'data:image/png;base64,'.base64_encode(UploadedFile::fake()->image('photo.png')->getContent());
|
$png = 'data:image/png;base64,'.base64_encode(UploadedFile::fake()->image('photo.png')->getContent());
|
||||||
|
|||||||
Reference in New Issue
Block a user