Add per-customer SMS and Bird Integrations for event messaging.
Deploy Ladill Events / deploy (push) Successful in 28s

Attendee and speaker comms use encrypted tenant relay credentials and skip platform-key fallback and Bird wallet double-debit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-12 17:05:54 +00:00
co-authored by Cursor
parent 13b2bd9702
commit 6e42cd1cb2
21 changed files with 1095 additions and 80 deletions
+39 -8
View File
@@ -4,8 +4,8 @@ namespace App\Services\Events;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\BillingClient;
use App\Services\Billing\SmsService;
use App\Services\Messaging\MessagingCredentialsService;
use Illuminate\Support\Collection;
class EventCommsService
@@ -21,11 +21,11 @@ class EventCommsService
public function __construct(
private readonly EventEmailService $email,
private readonly SmsService $sms,
private readonly BillingClient $billing,
private readonly MessagingCredentialsService $credentials,
private readonly EventProgrammeService $programmes,
) {}
/** @return array{recipients: int, emails: int, sms: int, estimated_ghs: float, affordable: bool} */
/** @return array{recipients: int, emails: int, sms: int, estimated_ghs: float, affordable: bool, integrations_error: ?string} */
public function preview(QrCode $event, string $mode): array
{
$regs = $this->confirmedRegistrations($event);
@@ -34,14 +34,15 @@ class EventCommsService
$smsSegments = $phones * $this->estimateSmsSegments($event, $mode);
$estimated = round(($emails * self::EMAIL_PRICE_GHS) + ($smsSegments * self::SMS_PRICE_GHS), 2);
$ownerRef = (string) $event->user->public_id;
$affordable = $estimated <= 0 || $this->billing->canAfford($ownerRef, (int) round($estimated * 100));
$integrationsError = $this->integrationsError($event, $mode, $emails, $phones);
return [
'recipients' => $regs->count(),
'emails' => $emails,
'sms' => $phones,
'estimated_ghs' => $estimated,
'affordable' => $affordable,
'affordable' => $integrationsError === null,
'integrations_error' => $integrationsError,
];
}
@@ -51,7 +52,7 @@ class EventCommsService
public function send(QrCode $event, string $mode): array
{
$preview = $this->preview($event, $mode);
abort_unless($preview['affordable'], 402, 'Insufficient wallet balance for this send.');
abort_if($preview['integrations_error'] !== null, 422, $preview['integrations_error']);
$programme = $this->programmeFor($event);
$eventName = $event->content()['name'] ?? $event->label;
@@ -86,8 +87,7 @@ class EventCommsService
if ($reg->attendee_phone) {
$message = $this->smsBody($reg, $eventName, $mode, $programmeUrl, $joinUrl);
if ($message !== '') {
$this->sms->send($ownerRef, $reg->attendee_phone, $message);
if ($message !== '' && $this->sms->send($ownerRef, $reg->attendee_phone, $message)) {
$texted++;
$sent = true;
}
@@ -167,4 +167,35 @@ class EventCommsService
return max(1, (int) ceil(strlen($sample) / self::SMS_SEGMENT_CHARS));
}
private function integrationsError(QrCode $event, string $mode, int $emails, int $phones): ?string
{
$ownerRef = (string) $event->user->public_id;
$credential = $this->credentials->forOwner($ownerRef);
$programmeUrl = $this->programmeFor($event)?->publicUrl();
$joinUrl = $this->primaryJoinUrl($event);
$needsEmail = $emails > 0 && (
(in_array($mode, [self::MODE_PROGRAMME, self::MODE_BOTH], true) && $programmeUrl)
|| ($mode === self::MODE_JOIN && $joinUrl !== '')
);
$needsSms = $phones > 0 && $this->smsBody(
new QrEventRegistration(['attendee_name' => 'Guest']),
$event->content()['name'] ?? $event->label,
$mode,
$programmeUrl,
$joinUrl,
) !== '';
if ($needsEmail && ! $credential->hasValidBird()) {
return 'Connect Ladill Bird in Integrations before sending attendee email.';
}
if ($needsSms && ! $credential->hasValidSms()) {
return 'Connect Ladill SMS in Integrations before sending attendee SMS.';
}
return null;
}
}