diff --git a/app/Http/Controllers/Admin/SmsPanelController.php b/app/Http/Controllers/Admin/SmsPanelController.php index 87863d0..17d0640 100644 --- a/app/Http/Controllers/Admin/SmsPanelController.php +++ b/app/Http/Controllers/Admin/SmsPanelController.php @@ -7,14 +7,103 @@ use App\Http\Controllers\Controller; use App\Models\SmsLedger; use App\Models\SmsSendLog; use App\Services\SmsBilling\SmsWalletService; +use App\Services\SmsBilling\SuperAdminSmsClient; use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; class SmsPanelController extends Controller { + private function sa(): SuperAdminSmsClient + { + return app(SuperAdminSmsClient::class); + } + + private function useSuperAdmin(): bool + { + return $this->sa()->enabled(); + } + + private function abortSaUnavailable(): void + { + abort(502, 'ارتباط با کیف پول پیامک سوپرادمین برقرار نشد.'); + } + + /** + * @param array $chargeInfo + * @return array{card_number: string, sheba: string, account_holder: string, note: string} + */ + private function depositFromChargeInfo(array $chargeInfo): array + { + return [ + 'card_number' => (string) ($chargeInfo['card'] ?? ''), + 'sheba' => (string) ($chargeInfo['iban'] ?? ''), + 'account_holder' => (string) ($chargeInfo['account_holder'] ?? ''), + 'note' => (string) ($chargeInfo['instruction'] ?? ''), + ]; + } + + /** + * @param array $data + */ + private function mirrorLocalFromSa(array $data): void + { + try { + $wallet = is_array($data['wallet'] ?? null) ? $data['wallet'] : $data; + + $balance = null; + if (isset($wallet['balance'])) { + $balance = (int) $wallet['balance']; + } elseif (isset($data['balance'])) { + $balance = (int) $data['balance']; + } + + $price = null; + if (isset($wallet['price_per_part'])) { + $price = (int) $wallet['price_per_part']; + } elseif (isset($data['price_per_part'])) { + $price = (int) $data['price_per_part']; + } + + $blocked = null; + if (is_array($data['wallet'] ?? null) && array_key_exists('is_blocked', $wallet)) { + $blocked = (bool) $wallet['is_blocked']; + } elseif (array_key_exists('is_blocked', $data)) { + $blocked = (bool) $data['is_blocked']; + } + + if ($balance === null && $price === null && $blocked === null) { + return; + } + + app(SmsWalletService::class)->mirrorFromSuperAdmin($balance, $price, $blocked); + + if (isset($data['charge_info']) && is_array($data['charge_info'])) { + SmsWalletService::setDepositInfo($this->depositFromChargeInfo($data['charge_info'])); + } + if (isset($data['deposit']) && is_array($data['deposit'])) { + SmsWalletService::setDepositInfo($data['deposit']); + } + } catch (\Throwable $e) { + report($e); + } + } + public function overview() { + if ($this->useSuperAdmin()) { + $data = $this->sa()->overview(); + if ($data === null) { + $this->abortSaUnavailable(); + } + + $charge = is_array($data['charge_info'] ?? null) ? $data['charge_info'] : []; + $data['deposit'] = $this->depositFromChargeInfo($charge); + $this->mirrorLocalFromSa($data); + + return response()->json($data); + } + $wallet = app(SmsWalletService::class)->getWallet(); $monthStart = Carbon::now()->startOfMonth(); @@ -67,6 +156,27 @@ class SmsPanelController extends Controller ]); } + if ($this->useSuperAdmin()) { + $balanceData = $this->sa()->balance(); + $charge = $this->sa()->chargeInfo(); + if ($balanceData === null || $charge === null) { + $this->abortSaUnavailable(); + } + + $this->mirrorLocalFromSa(array_merge($balanceData, [ + 'charge_info' => $charge, + 'deposit' => $this->depositFromChargeInfo($charge), + ])); + + return response()->json([ + 'enabled' => true, + 'balance' => (int) ($balanceData['balance'] ?? 0), + 'is_blocked' => (bool) ($balanceData['is_blocked'] ?? false), + 'deposit' => $this->depositFromChargeInfo($charge), + 'source' => 'superadmin', + ]); + } + $balance = 0; $isBlocked = false; @@ -96,6 +206,28 @@ class SmsPanelController extends Controller 'note' => 'nullable|string|max:2000', ]); + if ($this->useSuperAdmin()) { + $result = $this->sa()->updateChargeInfo([ + 'card' => (string) ($data['card_number'] ?? ''), + 'iban' => (string) ($data['sheba'] ?? ''), + 'account_holder' => (string) ($data['account_holder'] ?? ''), + 'instruction' => (string) ($data['note'] ?? ''), + ]); + if ($result === null) { + $this->abortSaUnavailable(); + } + + $charge = is_array($result['charge_info'] ?? null) ? $result['charge_info'] : []; + $deposit = $this->depositFromChargeInfo($charge); + $this->mirrorLocalFromSa(array_merge($result, ['deposit' => $deposit])); + + return response()->json([ + 'message' => (string) ($result['message'] ?? 'اطلاعات واریز به‌روزرسانی شد.'), + 'deposit' => $deposit, + 'source' => 'superadmin', + ]); + } + $deposit = SmsWalletService::setDepositInfo($data); return response()->json([ @@ -111,6 +243,21 @@ class SmsPanelController extends Controller 'note' => 'nullable|string|max:500', ]); + if ($this->useSuperAdmin()) { + $result = $this->sa()->charge((int) $data['amount'], $data['note'] ?? null); + if ($result === null) { + $this->abortSaUnavailable(); + } + + $this->mirrorLocalFromSa($result); + + return response()->json([ + 'message' => (string) ($result['message'] ?? 'کیف پول با موفقیت شارژ شد.'), + 'balance' => (int) ($result['balance'] ?? 0), + 'source' => 'superadmin', + ]); + } + $wallet = app(SmsWalletService::class)->charge( (int) $data['amount'], auth()->id(), @@ -129,6 +276,21 @@ class SmsPanelController extends Controller 'price_per_part' => 'required|integer|min:0', ]); + if ($this->useSuperAdmin()) { + $result = $this->sa()->updatePrice((int) $data['price_per_part']); + if ($result === null) { + $this->abortSaUnavailable(); + } + + $this->mirrorLocalFromSa($result); + + return response()->json([ + 'message' => (string) ($result['message'] ?? 'قیمت هر پارت به‌روزرسانی شد.'), + 'price_per_part' => (int) ($result['price_per_part'] ?? 0), + 'source' => 'superadmin', + ]); + } + $wallet = app(SmsWalletService::class)->updatePricePerPart((int) $data['price_per_part']); return response()->json([ @@ -143,6 +305,21 @@ class SmsPanelController extends Controller 'is_blocked' => 'required|boolean', ]); + if ($this->useSuperAdmin()) { + $result = $this->sa()->setBlocked((bool) $data['is_blocked']); + if ($result === null) { + $this->abortSaUnavailable(); + } + + $this->mirrorLocalFromSa($result); + + return response()->json([ + 'message' => (string) ($result['message'] ?? ''), + 'is_blocked' => (bool) ($result['is_blocked'] ?? false), + 'source' => 'superadmin', + ]); + } + $wallet = app(SmsWalletService::class)->setBlocked((bool) $data['is_blocked']); return response()->json([ @@ -153,6 +330,15 @@ class SmsPanelController extends Controller public function logsTable(Request $request) { + if ($this->useSuperAdmin()) { + $data = $this->sa()->logsTable($request->all()); + if ($data === null) { + $this->abortSaUnavailable(); + } + + return response()->json($data); + } + $records = SmsSendLog::query()->orderByDesc('id'); if ($request->filled('from_date')) { @@ -183,6 +369,15 @@ class SmsPanelController extends Controller public function ledgerTable(Request $request) { + if ($this->useSuperAdmin()) { + $data = $this->sa()->ledgerTable($request->all()); + if ($data === null) { + $this->abortSaUnavailable(); + } + + return response()->json($data); + } + $records = SmsLedger::query()->orderByDesc('id'); if ($request->filled('from_date')) { @@ -215,6 +410,15 @@ class SmsPanelController extends Controller $from = $request->input('from_date', Carbon::now()->startOfMonth()->toDateString()); $to = $request->input('to_date', Carbon::now()->toDateString()); + if ($this->useSuperAdmin()) { + $data = $this->sa()->report($from, $to); + if ($data === null) { + $this->abortSaUnavailable(); + } + + return response()->json($data); + } + $summary = SmsSendLog::query() ->whereDate('created_at', '>=', $from) ->whereDate('created_at', '<=', $to) @@ -250,6 +454,18 @@ class SmsPanelController extends Controller 'to_date' => 'nullable|date', ]); + if ($this->useSuperAdmin()) { + $data = $this->sa()->chargeReport( + $request->input('from_date'), + $request->input('to_date') + ); + if ($data === null) { + $this->abortSaUnavailable(); + } + + return response()->json($data); + } + $baseQuery = SmsLedger::query()->where('type', SmsWalletService::TYPE_CHARGE); if ($request->filled('from_date')) { diff --git a/app/Services/SmsBilling/SmsGatewayService.php b/app/Services/SmsBilling/SmsGatewayService.php index 0170052..5dd8140 100644 --- a/app/Services/SmsBilling/SmsGatewayService.php +++ b/app/Services/SmsBilling/SmsGatewayService.php @@ -146,6 +146,27 @@ class SmsGatewayService 'parts' => $parts, 'source' => $source, ]); + + return; + } + + // Parallel period: mirror balance + local send log/ledger from SuperAdmin. + try { + $this->walletService->mirrorConsumeFromSuperAdmin( + $to, + $text, + $parts, + $providerName, + $source, + $isAlert, + (int) ($consumed['balanceRials'] ?? 0), + (int) ($consumed['costRials'] ?? 0), + (int) ($consumed['pricePerPartRials'] ?? 0) + ); + } catch (\Throwable $e) { + Log::warning('SmsGateway: local mirror after SuperAdmin consume failed', [ + 'error' => $e->getMessage(), + ]); } return; diff --git a/app/Services/SmsBilling/SmsWalletService.php b/app/Services/SmsBilling/SmsWalletService.php index 03f1c66..431711f 100644 --- a/app/Services/SmsBilling/SmsWalletService.php +++ b/app/Services/SmsBilling/SmsWalletService.php @@ -91,6 +91,38 @@ class SmsWalletService return $wallet->fresh(); } + /** + * Keep local MySQL wallet mirrored with SuperAdmin during the parallel period. + */ + public function mirrorFromSuperAdmin(?int $balance = null, ?int $pricePerPart = null, ?bool $isBlocked = null): SmsWallet + { + return DB::transaction(function () use ($balance, $pricePerPart, $isBlocked) { + $wallet = SmsWallet::query()->lockForUpdate()->firstOrCreate([], [ + 'balance' => 0, + 'price_per_part' => 0, + 'is_blocked' => false, + 'alert_days_sent' => 0, + ]); + + if ($balance !== null) { + $wallet->balance = $balance; + } + if ($pricePerPart !== null) { + $wallet->price_per_part = max(0, $pricePerPart); + } + if ($isBlocked !== null) { + $wallet->is_blocked = $isBlocked; + } + if ($wallet->balance > 0) { + $wallet->alert_days_sent = 0; + $wallet->last_alert_date = null; + } + $wallet->save(); + + return $wallet->fresh(); + }); + } + public function updatePricePerPart(int $pricePerPart): SmsWallet { $wallet = $this->getWallet(); @@ -136,6 +168,74 @@ class SmsWalletService }); } + /** + * After SuperAdmin consume: set local balance to SA value and write local send log/ledger + * so the product SMS panel stays in sync during the parallel period. + */ + public function mirrorConsumeFromSuperAdmin( + string $receiver, + string $body, + int $parts, + string $provider, + ?string $source, + bool $isAlert, + int $balanceAfter, + int $costRials, + int $pricePerPart + ): void { + DB::transaction(function () use ( + $receiver, + $body, + $parts, + $provider, + $source, + $isAlert, + $balanceAfter, + $costRials, + $pricePerPart + ) { + $wallet = SmsWallet::query()->lockForUpdate()->firstOrCreate([], [ + 'balance' => 0, + 'price_per_part' => 0, + 'is_blocked' => false, + 'alert_days_sent' => 0, + ]); + + $wallet->balance = $balanceAfter; + if ($pricePerPart > 0) { + $wallet->price_per_part = $pricePerPart; + } + if ($wallet->balance > 0) { + $wallet->alert_days_sent = 0; + $wallet->last_alert_date = null; + } + $wallet->save(); + + $log = SmsSendLog::create([ + 'receiver' => $receiver, + 'body' => $body, + 'parts' => $parts, + 'price_per_part' => $pricePerPart, + 'total_amount' => $costRials, + 'provider' => $provider, + 'source' => $source, + 'is_alert' => $isAlert, + 'created_at' => now(), + ]); + + SmsLedger::create([ + 'type' => $isAlert ? self::TYPE_ALERT_DEBIT : self::TYPE_DEBIT, + 'amount' => -$costRials, + 'balance_after' => $balanceAfter, + 'price_per_part' => $pricePerPart, + 'parts' => $parts, + 'sms_send_log_id' => $log->id, + 'note' => $isAlert ? 'هشدار موجودی پیامک' : 'مصرف ارسال (superadmin)', + 'created_at' => now(), + ]); + }); + } + /** * @return array{log: SmsSendLog, wallet: SmsWallet} */ diff --git a/app/Services/SmsBilling/SuperAdminSmsClient.php b/app/Services/SmsBilling/SuperAdminSmsClient.php index 02abcf0..13a96f1 100644 --- a/app/Services/SmsBilling/SuperAdminSmsClient.php +++ b/app/Services/SmsBilling/SuperAdminSmsClient.php @@ -2,14 +2,18 @@ namespace App\Services\SmsBilling; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; /** - * SuperAdmin SMS wallet client (status / consume). + * SuperAdmin SMS wallet client (status / consume / panel sync). * - * Preferred: SUPERADMIN_SMS_BASE_URL + SUPERADMIN_SMS_SHARED_SECRET (+ optional DOMAIN) - * Fallback: SUPERADMIN_REMOTE_TOKEN + * Preferred auth: shared HMAC + domain (one secret for all sold versions). + * Env: SUPERADMIN_SMS_BASE_URL + SUPERADMIN_SMS_SHARED_SECRET + * Optional: SUPERADMIN_SMS_DOMAIN (else host of APP_URL) + * + * Fallback: SUPERADMIN_REMOTE_TOKEN (per-tenant Bearer) if shared secret missing. */ class SuperAdminSmsClient { @@ -36,50 +40,24 @@ class SuperAdminSmsClient */ public function status(int $parts = 1, bool $isAlert = false): ?array { - if (! $this->enabled()) { + $data = $this->requestJson('GET', '/api/remote/sms/status', 'status', [ + 'parts' => max(1, $parts), + 'isAlert' => $isAlert ? '1' : '0', + ], null, 4, 'status'); + + if ($data === null) { 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 [ + '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'), + ]; } /** @@ -93,27 +71,136 @@ class SuperAdminSmsClient ?string $source = null, bool $isAlert = false ): ?array { - if (! $this->enabled()) { + $data = $this->requestJson('POST', '/api/remote/sms/consume', 'consume', [], [ + 'receiver' => $receiver, + 'body' => mb_substr($body, 0, 1000), + 'parts' => max(1, $parts), + 'provider' => $provider, + 'source' => $source, + 'isAlert' => $isAlert, + ], 6, 'consume'); + + if ($data === null || !($data['ok'] ?? false)) { + return null; + } + + return [ + 'ok' => true, + 'balanceRials' => (int) ($data['balanceRials'] ?? 0), + 'costRials' => (int) ($data['costRials'] ?? 0), + 'pricePerPartRials' => (int) ($data['pricePerPartRials'] ?? 0), + ]; + } + + public function overview(): ?array + { + return $this->requestJson('GET', '/api/remote/sms/overview', 'overview', [], null, 6, 'overview'); + } + + public function balance(): ?array + { + return $this->requestJson('GET', '/api/remote/sms/balance', 'balance', [], null, 4, 'balance'); + } + + public function chargeInfo(): ?array + { + return $this->requestJson('GET', '/api/remote/sms/charge-info', 'charge-info', [], null, 4, 'charge-info'); + } + + public function updateChargeInfo(array $payload): ?array + { + return $this->requestJson('PUT', '/api/remote/sms/charge-info', 'charge-info', [], $payload, 6, 'charge-info-update'); + } + + public function charge(int $amount, ?string $note = null): ?array + { + return $this->requestJson('POST', '/api/remote/sms/charge', 'charge', [], [ + 'amount' => $amount, + 'note' => $note, + ], 8, 'charge'); + } + + public function updatePrice(int $pricePerPart): ?array + { + return $this->requestJson('PUT', '/api/remote/sms/price', 'price', [], [ + 'price_per_part' => max(0, $pricePerPart), + ], 6, 'price'); + } + + public function setBlocked(bool $isBlocked): ?array + { + return $this->requestJson('PUT', '/api/remote/sms/block', 'block', [], [ + 'is_blocked' => $isBlocked, + ], 6, 'block'); + } + + public function logsTable(array $payload): ?array + { + return $this->requestJson('POST', '/api/remote/sms/logs/table', 'logs/table', [], $payload, 10, 'logs-table'); + } + + public function ledgerTable(array $payload): ?array + { + return $this->requestJson('POST', '/api/remote/sms/ledger/table', 'ledger/table', [], $payload, 10, 'ledger-table'); + } + + public function report(?string $fromDate = null, ?string $toDate = null): ?array + { + $query = array_filter([ + 'from_date' => $fromDate, + 'to_date' => $toDate, + ], static fn ($v) => $v !== null && $v !== ''); + + return $this->requestJson('GET', '/api/remote/sms/report', 'report', $query, null, 8, 'report'); + } + + public function chargeReport(?string $fromDate = null, ?string $toDate = null): ?array + { + $query = array_filter([ + 'from_date' => $fromDate, + 'to_date' => $toDate, + ], static fn ($v) => $v !== null && $v !== ''); + + return $this->requestJson('GET', '/api/remote/sms/report/charges', 'report/charges', $query, null, 8, 'charge-report'); + } + + /** + * @param array $query + * @param array|null $body + * @return array|null + */ + private function requestJson( + string $method, + string $canonicalPath, + string $relativeUrl, + array $query = [], + ?array $body = null, + int $timeout = 6, + string $label = 'request' + ): ?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)) + $pending = Http::withHeaders($this->authHeaders($method, $canonicalPath)) ->acceptJson() - ->timeout(6) - ->post($this->url('consume'), $payload); + ->timeout($timeout); - if (! $response->successful()) { - Log::error('SuperAdmin SMS consume failed', [ + $url = $this->url($relativeUrl); + $method = strtoupper($method); + + /** @var Response $response */ + $response = match ($method) { + 'GET' => $pending->get($url, $query), + 'POST' => $pending->post($url, $body ?? []), + 'PUT' => $pending->put($url, $body ?? []), + 'PATCH' => $pending->patch($url, $body ?? []), + default => throw new \InvalidArgumentException("Unsupported method {$method}"), + }; + + if (!$response->successful()) { + Log::warning("SuperAdmin SMS {$label} failed", [ 'status' => $response->status(), 'body' => $response->body(), ]); @@ -122,17 +209,10 @@ class SuperAdminSmsClient } $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), - ]; + return is_array($data) ? $data : null; } catch (\Throwable $e) { - Log::error('SuperAdmin SMS consume exception', [ + Log::warning("SuperAdmin SMS {$label} exception", [ 'error' => $e->getMessage(), ]);