isConfigured()) { return self::FAILED; } $projectId = (string) config('services.fcm.project_id'); $accessToken = $this->accessToken(); $response = Http::withToken($accessToken) ->timeout(15) ->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", [ 'message' => [ 'token' => $fcmToken, 'notification' => compact('title', 'body'), 'data' => array_map('strval', $data), 'android' => [ 'priority' => 'high', 'notification' => [ 'sound' => 'default', 'channel_id' => 'payments', ], ], ], ]); if ($response->successful()) { return self::DELIVERED; } $outcome = $this->classifyFailure($response); Log::warning('FCM push failed', [ 'token' => substr($fcmToken, 0, 20).'…', 'status' => $response->status(), 'outcome' => $outcome, 'body' => $response->body(), ]); return $outcome; } /** * @return self::INVALID_TOKEN|self::FAILED */ private function classifyFailure(Response $response): string { $statusCode = $response->status(); $body = strtolower((string) $response->body()); $apiStatus = strtoupper((string) data_get($response->json(), 'error.status', '')); if ($statusCode === 404 || $apiStatus === 'NOT_FOUND' || str_contains($body, 'not_found') || str_contains($body, 'requested entity was not found') || str_contains($body, 'registration token is not a valid') || str_contains($body, 'invalid registration') || str_contains($body, 'unregistered')) { return self::INVALID_TOKEN; } return self::FAILED; } private function accessToken(): string { return Cache::remember('mini_fcm_access_token', 3300, function (): string { $sa = $this->serviceAccount(); $jwt = $this->buildJwt($sa); $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt, ]); if (! $response->successful()) { throw new \RuntimeException('FCM OAuth2 token exchange failed: '.$response->body()); } return $response->json('access_token'); }); } private function buildJwt(array $sa): string { $header = $this->base64url(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); $now = time(); $payload = $this->base64url(json_encode([ 'iss' => $sa['client_email'], 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', 'aud' => 'https://oauth2.googleapis.com/token', 'iat' => $now, 'exp' => $now + 3600, ])); $message = "{$header}.{$payload}"; openssl_sign($message, $signature, $sa['private_key'], 'sha256WithRSAEncryption'); return "{$message}.{$this->base64url($signature)}"; } private function base64url(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } /** @return array */ private function serviceAccount(): array { $value = config('services.fcm.service_account_json'); if (blank($value)) { throw new \RuntimeException('FCM service account JSON is not configured.'); } $json = is_file($value) ? file_get_contents($value) : $value; $decoded = json_decode((string) $json, true); if (! is_array($decoded) || empty($decoded['private_key'])) { throw new \RuntimeException('FCM service account JSON is invalid or missing private_key.'); } return $decoded; } }