diff --git a/app/Services/SmsBilling/SmsGatewayService.php b/app/Services/SmsBilling/SmsGatewayService.php index 16ae92f..0170052 100644 --- a/app/Services/SmsBilling/SmsGatewayService.php +++ b/app/Services/SmsBilling/SmsGatewayService.php @@ -4,7 +4,6 @@ namespace App\Services\SmsBilling; use App\Exceptions\SmsBlockedException; use App\Models\Setting; -use App\Models\User; use App\Services\SmsBilling\Contracts\SmsProviderInterface; use App\Services\SmsBilling\Providers\CentralSmsApiProvider; use App\Services\SmsBilling\Providers\MeliPayamakProvider; @@ -19,6 +18,7 @@ class SmsGatewayService private SmsWalletService $walletService, private SmsSegmentCalculator $segmentCalculator, private SmsAlertService $alertService, + private SuperAdminSmsClient $superAdminSms, MeliPayamakProvider $meliPayamakProvider, CentralSmsApiProvider $centralSmsApiProvider, ) { @@ -35,9 +35,17 @@ class SmsGatewayService bool $isAlert = false ): SmsSendResult { $config = $this->buildProviderConfig(); - $billingOperational = SmsWalletService::isBillingOperational(); + $useSuperAdmin = $this->superAdminSms->enabled(); + $billingOperational = SmsWalletService::isBillingOperational() || $useSuperAdmin; + $text = trim($message); - if ($billingOperational && $this->walletService->isBlocked()) { + if ($text === '') { + return SmsSendResult::failed('متن پیامک خالی است.'); + } + + $partsEstimate = max(1, $this->segmentCalculator->countParts($text)); + + if ($billingOperational && ! $this->maySend($partsEstimate, $isAlert)) { return SmsSendResult::blocked(); } @@ -49,12 +57,6 @@ class SmsGatewayService return SmsSendResult::failed('سرویس پیامک پیکربندی نشده است.'); } - $text = trim($message); - - if ($text === '') { - return SmsSendResult::failed('متن پیامک خالی است.'); - } - try { $result = $provider->send($to, $text, $config); @@ -77,18 +79,7 @@ class SmsGatewayService $providerName = $this->providerLabel($provider); if ($billingOperational) { - $debit = $this->walletService->debitSuccessfulSms( - $to, - $text, - $parts, - $providerName, - $source, - $isAlert - ); - - if (! $isAlert) { - $this->alertService->maybeSendLowBalanceAlerts($debit['wallet']); - } + $this->billAfterSuccess($to, $text, $parts, $providerName, $source, $isAlert); } return SmsSendResult::success($providerName); @@ -117,6 +108,63 @@ class SmsGatewayService return $result; } + private function maySend(int $parts, bool $isAlert): bool + { + if ($this->superAdminSms->enabled()) { + $status = $this->superAdminSms->status($parts, $isAlert); + if ($status === null) { + return ! $this->walletService->isBlocked(); + } + + return (bool) $status['allowed']; + } + + return ! $this->walletService->isBlocked(); + } + + private function billAfterSuccess( + string $to, + string $text, + int $parts, + string $providerName, + ?string $source, + bool $isAlert + ): void { + if ($this->superAdminSms->enabled()) { + $consumed = $this->superAdminSms->consume( + $to, + $text, + $parts, + $providerName, + $source, + $isAlert + ); + + if ($consumed === null) { + Log::error('SmsGateway: SuperAdmin consume failed after successful send', [ + 'to' => $to, + 'parts' => $parts, + 'source' => $source, + ]); + } + + return; + } + + $debit = $this->walletService->debitSuccessfulSms( + $to, + $text, + $parts, + $providerName, + $source, + $isAlert + ); + + if (! $isAlert) { + $this->alertService->maybeSendLowBalanceAlerts($debit['wallet']); + } + } + private function buildProviderConfig(): array { return [ diff --git a/app/Services/SmsBilling/SuperAdminSmsClient.php b/app/Services/SmsBilling/SuperAdminSmsClient.php new file mode 100644 index 0000000..02abcf0 --- /dev/null +++ b/app/Services/SmsBilling/SuperAdminSmsClient.php @@ -0,0 +1,210 @@ +sharedSecret() !== '' || $this->bearerToken() !== ''; + } + + /** + * @return array{ + * allowed: bool, + * blocked: bool, + * reason: ?string, + * balanceRials: int, + * pricePerPartRials: int, + * estimatedCostRials: int, + * balanceLevel: string + * }|null + */ + public function status(int $parts = 1, bool $isAlert = false): ?array + { + if (! $this->enabled()) { + return null; + } + + try { + $path = '/api/remote/sms/status'; + $response = Http::withHeaders($this->authHeaders('GET', $path)) + ->acceptJson() + ->timeout(4) + ->get($this->url('status'), [ + 'parts' => max(1, $parts), + 'isAlert' => $isAlert ? '1' : '0', + ]); + + if (! $response->successful()) { + Log::warning('SuperAdmin SMS status failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return null; + } + + $data = $response->json(); + if (! is_array($data)) { + return null; + } + + return [ + 'allowed' => (bool) ($data['allowed'] ?? false), + 'blocked' => (bool) ($data['blocked'] ?? false), + 'reason' => isset($data['reason']) ? (string) $data['reason'] : null, + 'balanceRials' => (int) ($data['balanceRials'] ?? 0), + 'pricePerPartRials' => (int) ($data['pricePerPartRials'] ?? 0), + 'estimatedCostRials' => (int) ($data['estimatedCostRials'] ?? 0), + 'balanceLevel' => (string) ($data['balanceLevel'] ?? 'unknown'), + ]; + } catch (\Throwable $e) { + Log::warning('SuperAdmin SMS status exception', [ + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * @return array{ok: bool, balanceRials?: int, costRials?: int}|null + */ + public function consume( + string $receiver, + string $body, + int $parts, + ?string $provider = null, + ?string $source = null, + bool $isAlert = false + ): ?array { + if (! $this->enabled()) { + return null; + } + + try { + $path = '/api/remote/sms/consume'; + $payload = [ + 'receiver' => $receiver, + 'body' => mb_substr($body, 0, 1000), + 'parts' => max(1, $parts), + 'provider' => $provider, + 'source' => $source, + 'isAlert' => $isAlert, + ]; + $response = Http::withHeaders($this->authHeaders('POST', $path)) + ->acceptJson() + ->timeout(6) + ->post($this->url('consume'), $payload); + + if (! $response->successful()) { + Log::error('SuperAdmin SMS consume failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return null; + } + + $data = $response->json(); + if (! is_array($data) || ! ($data['ok'] ?? false)) { + return null; + } + + return [ + 'ok' => true, + 'balanceRials' => (int) ($data['balanceRials'] ?? 0), + 'costRials' => (int) ($data['costRials'] ?? 0), + ]; + } catch (\Throwable $e) { + Log::error('SuperAdmin SMS consume exception', [ + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * @return array + */ + private function authHeaders(string $method, string $canonicalPath): array + { + $secret = $this->sharedSecret(); + if ($secret !== '') { + $domain = $this->domain(); + $timestamp = (string) time(); + $payload = $timestamp.'.'.strtoupper($method).'.'.$canonicalPath.'.'.$domain; + $signature = hash_hmac('sha256', $payload, $secret); + + return [ + 'X-Abshode-Domain' => $domain, + 'X-Abshode-Timestamp' => $timestamp, + 'X-Abshode-Signature' => $signature, + ]; + } + + $token = $this->bearerToken(); + if ($token !== '') { + return [ + 'Authorization' => 'Bearer '.$token, + ]; + } + + return []; + } + + private function sharedSecret(): string + { + return trim((string) config('services.superadmin.sms_shared_secret', '')); + } + + private function bearerToken(): string + { + return trim((string) config('services.superadmin.remote_token', '')); + } + + private function domain(): string + { + $override = trim((string) config('services.superadmin.sms_domain', '')); + if ($override !== '') { + return $this->normalizeHost($override); + } + + $fromApp = parse_url((string) config('app.url'), PHP_URL_HOST); + + return $this->normalizeHost(is_string($fromApp) ? $fromApp : ''); + } + + private function normalizeHost(string $value): string + { + $host = strtolower(trim($value)); + $host = preg_replace('#^https?://#', '', $host) ?? $host; + $host = explode('/', $host)[0] ?? $host; + $host = explode(':', $host)[0] ?? $host; + + return $host; + } + + private function url(string $path): string + { + $base = rtrim((string) config('services.superadmin.sms_base_url', ''), '/'); + + return $base.'/'.ltrim($path, '/'); + } +} diff --git a/config/services.php b/config/services.php index aa52f9b..7da9b96 100644 --- a/config/services.php +++ b/config/services.php @@ -41,4 +41,15 @@ return [ 'secret' => env('NODE_SHARED_SECRET'), ], + /* + | SuperAdmin SMS wallet (source of truth when both env vars are set). + | Example: SUPERADMIN_SMS_BASE_URL=https://adm.montzar.ir/api/remote/sms + */ + 'superadmin' => [ + 'sms_base_url' => env('SUPERADMIN_SMS_BASE_URL'), + 'sms_shared_secret' => env('SUPERADMIN_SMS_SHARED_SECRET'), + 'sms_domain' => env('SUPERADMIN_SMS_DOMAIN'), + 'remote_token' => env('SUPERADMIN_REMOTE_TOKEN'), + ], + ];