= 0x80 as UCS-2 except € which is allowed via extension table. */ protected function isGsm7(string $text): bool { $len = mb_strlen($text, 'UTF-8'); for ($i = 0; $i < $len; $i++) { $ch = mb_substr($text, $i, 1, 'UTF-8'); if ($ch === '€') { continue; } // Fast ASCII check: multibyte UTF-8 means non-ASCII (>= 0x80) if (strlen($ch) > 1) { return false; } } return true; } /** * Count GSM-7 units, where extension table chars cost 2 (ESC + char). */ protected function gsm7Length(string $text): int { static $extended = null; if ($extended === null) { $extended = ['^', '{', '}', '\\', '[', '~', ']', '|']; } $len = 0; $strlen = mb_strlen($text, 'UTF-8'); for ($i = 0; $i < $strlen; $i++) { $ch = mb_substr($text, $i, 1, 'UTF-8'); if ($ch === '€' || in_array($ch, $extended, true)) { $len += 2; } else { $len += 1; } } return $len; } /** * Truncate text to provider hard limits: 640 GSM-7 units or 320 UCS-2 units. */ public function enforceLengthLimit(string $text): string { $text = $this->normalizeForSms($text); $isGsm = $this->isGsm7($text); $limit = $isGsm ? 640 : 320; if ($isGsm) { // Fast-path: if within limit, return if ($this->gsm7Length($text) <= $limit) { return $text; } // Truncate respecting extension char cost $out = ''; $acc = 0; $strlen = mb_strlen($text, 'UTF-8'); for ($i = 0; $i < $strlen; $i++) { $ch = mb_substr($text, $i, 1, 'UTF-8'); $cost = ($ch === '€' || in_array($ch, ['^', '{', '}', '\\', '[', '~', ']', '|'], true)) ? 2 : 1; if ($acc + $cost > $limit) { break; } $out .= $ch; $acc += $cost; } return $out; } // UCS-2: count by UTF-16 code units; approximate via mb_substr slicing by codepoints // We use mb_substr which handles Unicode correctly for most cases; providers count 1 per code unit. // For BMP characters (most cases like Slovenian diacritics), 1 codepoint ~= 1 code unit. if (mb_strlen($text, 'UTF-8') <= $limit) { return $text; } return mb_substr($text, 0, $limit, 'UTF-8'); } /** * Send a raw text message. */ public function sendRaw(SmsProfile $profile, string $to, string $content, ?SmsSender $sender = null, ?string $countryCode = null, bool $deliveryReport = false, ?string $clientReference = null): SmsLog { return DB::transaction(function () use ($profile, $to, $content, $sender, $countryCode, $deliveryReport, $clientReference): SmsLog { // Enforce provider hard length limits before logging/sending $content = $this->enforceLengthLimit($content); $log = new SmsLog([ 'uuid' => (string) Str::uuid(), 'profile_id' => $profile->id, 'to_number' => $to, 'sender' => $sender?->sname, 'message' => $content, 'status' => 'queued', 'queued_at' => now(), ]); $log->save(); $result = $this->client->send($profile, new SmsMessage( to: $to, content: $content, sender: $sender?->sname, senderPhone: $sender?->phone_number, countryCode: $countryCode, deliveryReport: $deliveryReport, clientReference: $clientReference, )); if ($result->status === 'sent') { $log->status = 'sent'; $log->sent_at = now(); } else { $log->status = 'failed'; $log->failed_at = now(); } $log->provider_message_id = $result->providerMessageId; $log->cost = $result->cost; $log->currency = $result->currency; $log->meta = $result->meta; $log->save(); return $log; }); } /** * Render an SMS from template and send. */ public function sendFromTemplate(SmsTemplate $template, string $to, array $variables = [], ?SmsProfile $profile = null, ?SmsSender $sender = null, ?string $countryCode = null, bool $deliveryReport = false, ?string $clientReference = null): SmsLog { $profile = $profile ?: $template->defaultProfile; if (! $profile) { throw new \InvalidArgumentException('SMS profile is required to send a message.'); } $sender = $sender ?: $template->defaultSender; $content = $this->renderContent($template->content, $variables); $log = $this->sendRaw($profile, $to, $content, $sender, $countryCode, $deliveryReport, $clientReference); $log->template_id = $template->id; $log->save(); return $log; } public function renderContent(string $content, array $vars): string { // Support {token} and {nested.keys} using dot-notation lookup $resolver = function (array $arr, string $path) { if (array_key_exists($path, $arr)) { return $arr[$path]; } $segments = explode('.', $path); $cur = $arr; foreach ($segments as $seg) { if (is_array($cur) && array_key_exists($seg, $cur)) { $cur = $cur[$seg]; } else { return null; } } return $cur; }; return preg_replace_callback('/\{([a-zA-Z0-9_\.]+)\}/', function ($m) use ($vars, $resolver) { $key = $m[1]; $val = $resolver($vars, $key); return $val !== null ? (string) $val : $m[0]; }, $content); } /** * Format a number to EU style: thousands separated by '.', decimals by ','. */ public function formatAmountEu(mixed $value, int $decimals = 2): string { if ($value === null || $value === '') { return number_format(0, $decimals, ',', '.'); } $str = (string) $value; // Normalize possible EU-style input like "1.234,56" to standard for float casting if (str_contains($str, ',')) { $str = str_replace(['.', ','], ['', '.'], $str); } $num = (float) $str; return number_format($num, $decimals, ',', '.'); } /** * Get current credit balance from provider. */ public function getCreditBalance(SmsProfile $profile): string { return $this->client->getCreditBalance($profile); } /** * Get price quote(s) from provider. * Returns array of strings as provided by the API. */ public function getPriceQuotes(SmsProfile $profile): array { return $this->client->getPriceQuotes($profile); } }