Add freemium plans, wallet-billed host notifications, and Pro upgrades.
Deploy Ladill Frontdesk / deploy (push) Successful in 44s

Host alerts use email/phone on the host record without requiring a Ladill
account link; SMS and over-quota emails debit the org wallet at platform rates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-28 02:00:26 +00:00
co-authored by Cursor
parent 5a42759886
commit 33b6070207
23 changed files with 912 additions and 20 deletions
@@ -19,11 +19,12 @@ class NotificationDispatcher
protected EmailService $email,
protected SmsService $sms,
protected NotificationPreferenceService $preferences,
protected NotificationBillingService $billing,
) {}
public function visitorArrived(Visit $visit): void
public function visitorArrived(Visit $visit): bool
{
$this->dispatchVisitEvent(
return $this->dispatchVisitEvent(
$visit,
'visitor_arrived',
'Visitor has arrived',
@@ -149,15 +150,15 @@ class NotificationDispatcher
string $title,
string $message,
array $meta = [],
): void {
): bool {
$visit->load(['visitor', 'host', 'organization']);
$channels = $this->preferences->channelsForEvent($visit->organization, $event);
if ($channels === [] || ! $visit->host) {
return;
return false;
}
$this->notifyHost($visit->host, $visit->organization, $channels, $title, $message, $visit, $event, $meta);
return $this->notifyHost($visit->host, $visit->organization, $channels, $title, $message, $visit, $event, $meta);
}
/** @param list<string> $channels */
@@ -170,17 +171,45 @@ class NotificationDispatcher
?Visit $visit = null,
?string $event = null,
array $meta = [],
): void {
): bool {
$notified = false;
$reference = $visit ? 'visit-'.$visit->id.'-'.($event ?? 'alert') : 'host-'.$host->id;
if (in_array('email', $channels, true) && $host->email) {
try {
$this->email->send($host->email, $title, $message);
} catch (\Throwable $e) {
Log::warning('Frontdesk host email failed', ['host_id' => $host->id, 'error' => $e->getMessage()]);
if (! $this->billing->canAffordEmail($organization)) {
Log::info('Frontdesk host email skipped — insufficient wallet balance', [
'host_id' => $host->id,
'organization_id' => $organization->id,
]);
} else {
try {
$this->email->send($host->email, $title, $message);
if ($this->billing->chargeEmail($organization, $reference, "Host alert: {$title}")) {
$notified = true;
}
} catch (\Throwable $e) {
Log::warning('Frontdesk host email failed', ['host_id' => $host->id, 'error' => $e->getMessage()]);
}
}
}
if (in_array('sms', $channels, true) && $host->phone) {
$this->sms->send($host->phone, "{$title}: {$message}");
$smsBody = "{$title}: {$message}";
if (! $this->billing->canAffordSms($organization, $smsBody)) {
Log::info('Frontdesk host SMS skipped — insufficient wallet balance', [
'host_id' => $host->id,
'organization_id' => $organization->id,
]);
} else {
try {
$this->sms->send($host->phone, $smsBody);
if ($this->billing->chargeSms($organization, $smsBody, $reference, "Host SMS: {$title}")) {
$notified = true;
}
} catch (\Throwable $e) {
Log::warning('Frontdesk host SMS failed', ['host_id' => $host->id, 'error' => $e->getMessage()]);
}
}
}
if ($host->user_ref) {
@@ -193,6 +222,8 @@ class NotificationDispatcher
])));
}
}
return $notified;
}
protected function notifyStaffUsers(