onQueue('sms'); } public function handle(SmsService $sms): void { /** @var PackageItem|null $item */ $item = PackageItem::query()->find($this->packageItemId); if (! $item) { return; } /** @var Package $package */ $package = $item->package; if (! $package || $package->status === Package::STATUS_CANCELED) { return; // canceled or missing } // Skip if already finalized to avoid double counting on retries if (in_array($item->status, ['sent', 'failed', 'canceled', 'skipped'], true)) { return; } // Mark processing on first entry if ($item->status === 'queued') { $item->status = 'processing'; $item->save(); $package->increment('processing_count'); } $payload = (array) $item->payload_json; $target = (array) $item->target_json; $profileId = $payload['profile_id'] ?? null; $senderId = $payload['sender_id'] ?? null; $templateId = $payload['template_id'] ?? null; $deliveryReport = (bool) ($payload['delivery_report'] ?? false); $variables = (array) ($payload['variables'] ?? []); // Enrich variables with contract/account context when available (contracts-based packages) if (! empty($target['contract_id'])) { $contract = Contract::query()->with('account.type')->find($target['contract_id']); if ($contract) { $variables['contract'] = [ 'id' => $contract->id, 'uuid' => $contract->uuid, 'reference' => $contract->reference, 'start_date' => (string) ($contract->start_date ?? ''), 'end_date' => (string) ($contract->end_date ?? ''), ]; if ($contract->account) { // Preserve raw values and provide EU-formatted versions for SMS rendering $initialRaw = (string) $contract->account->initial_amount; $balanceRaw = (string) $contract->account->balance_amount; $variables['account'] = [ 'id' => $contract->account->id, 'reference' => $contract->account->reference, // Override placeholders with EU formatted values for SMS 'initial_amount' => $sms->formatAmountEu($initialRaw), 'balance_amount' => $sms->formatAmountEu($balanceRaw), // Expose raw values too in case templates need them explicitly 'initial_amount_raw' => $initialRaw, 'balance_amount_raw' => $balanceRaw, 'type' => $contract->account->type?->name, ]; } } } $bodyOverride = isset($payload['body']) ? trim((string) $payload['body']) : null; if ($bodyOverride === '') { $bodyOverride = null; } /** @var SmsProfile|null $profile */ $profile = $profileId ? SmsProfile::find($profileId) : null; /** @var SmsSender|null $sender */ $sender = $senderId ? SmsSender::find($senderId) : null; /** @var SmsTemplate|null $template */ $template = $templateId ? SmsTemplate::with(['action', 'decision'])->find($templateId) : null; $to = $target['number'] ?? null; if (! is_string($to) || $to === '') { $item->status = 'failed'; $item->last_error = 'Missing recipient number.'; $item->save(); return; } // Compute throttle key $scope = config('services.sms.throttle.scope', 'global'); $provider = config('services.sms.throttle.provider_key', 'smsapi_si'); $allow = (int) config('services.sms.throttle.allow', 30); $every = (int) config('services.sms.throttle.every', 60); $jitter = (int) config('services.sms.throttle.jitter_seconds', 2); $key = $scope === 'per_profile' && $profile ? "sms:{$provider}:{$profile->id}" : "sms:{$provider}"; // Throttle $sendClosure = function () use ($sms, $item, $package, $profile, $sender, $template, $to, $variables, $deliveryReport, $bodyOverride, $target) { // Idempotency key (optional external use) if (empty($item->idempotency_key)) { $hash = sha1(implode('|', [ 'sms', (string) ($profile?->id ?? ''), (string) ($sender?->id ?? ''), (string) ($template?->id ?? ''), $to, (string) ($bodyOverride ?? ''), json_encode($variables), ])); $item->idempotency_key = "pkgitem:{$item->id}:{$hash}"; $item->save(); } // Decide whether to use template or raw content $useTemplate = false; if ($template) { if ($bodyOverride) { // If custom body is provided but template does not allow it, force template $useTemplate = ! (bool) ($template->allow_custom_body ?? false); } else { // No custom body provided -> use template $useTemplate = true; } } if ($useTemplate) { $log = $sms->sendFromTemplate( template: $template, to: $to, variables: $variables, profile: $profile, sender: $sender, countryCode: null, deliveryReport: $deliveryReport, clientReference: "pkg:{$item->package_id}:item:{$item->id}", ); } else { // Either explicit body override or no template $effectiveBody = (string) ($bodyOverride ?? ''); if ($effectiveBody === '') { // Avoid provider error for empty body throw new \RuntimeException('Empty SMS body and no template provided.'); } if (! $profile && $template) { $profile = $template->defaultProfile; } if (! $profile) { throw new \RuntimeException('Missing SMS profile for raw send.'); } $log = $sms->sendRaw( profile: $profile, to: $to, content: $effectiveBody, sender: $sender, countryCode: null, deliveryReport: $deliveryReport, clientReference: "pkg:{$item->package_id}:item:{$item->id}", ); } $newStatus = $log->status === 'sent' ? 'sent' : 'failed'; $item->status = $newStatus; $item->provider_message_id = $log->provider_message_id; $item->cost = $log->cost; $item->currency = $log->currency; // Persist useful result info including final rendered message for auditing $result = $log->meta ?? []; $result['message'] = $log->message ?? (($useTemplate && isset($template)) ? $sms->renderContent($template->content, $variables) : ($bodyOverride ?? null)); $result['template_id'] = $template?->id; $result['render_source'] = $useTemplate ? 'template' : 'body'; $item->result_json = $result; $item->last_error = $log->status === 'sent' ? null : ($log->meta['error_message'] ?? 'Failed'); $item->save(); // Create activity if template has action_id and decision_id configured and SMS was sent successfully if ($newStatus === 'sent' && $template && ($template->action_id || $template->decision_id)) { if (! empty($target['contract_id'])) { $contract = Contract::query()->with('clientCase')->find($target['contract_id']); if ($contract && $contract->client_case_id) { \App\Models\Activity::create(array_filter([ 'client_case_id' => $contract->client_case_id, 'contract_id' => $contract->id, 'action_id' => $template->action_id, 'decision_id' => $template->decision_id, 'note' => "SMS poslan na {$to}: {$result['message']}", 'created_at' => now(), 'updated_at' => now(), ])); } } } // Update package counters atomically if ($newStatus === 'sent') { $package->increment('sent_count'); } else { $package->increment('failed_count'); } // If all items processed, finalize package $package->refresh(); if (($package->sent_count + $package->failed_count) >= $package->total_items) { $finalStatus = $package->failed_count > 0 ? Package::STATUS_FAILED : Package::STATUS_COMPLETED; $package->status = $finalStatus; $package->finished_at = now(); $package->save(); } }; try { Redis::throttle($key)->allow($allow)->every($every)->then($sendClosure, function () use ($jitter) { return $this->release(max(1, rand(1, $jitter))); }); } catch (\Throwable $e) { // Fallback to direct send when Redis unavailable (e.g., test environment) $sendClosure(); } } }