Suscripciones sin presupuesto ni alerta de gasto
Te dice qué suscripciones no tienen un presupuesto de Cost Management o lo tienen sin ninguna notificación activa, para que un gasto inesperado no te tome por sorpresa.
Ejemplo en México
Universidad privada en León, 3,000 alumnos
Un laboratorio dejó encendidas máquinas costosas todo un semestre y nadie se enteró hasta ver la factura.
.\get-suscripciones-sin-presupuesto.ps1 -RutaCsv .\presupuestos.csvDetecta que las suscripciones de los laboratorios no tienen presupuesto y les pone alertas antes de iniciar el siguiente ciclo escolar.
Cómo implementarlo
- Corre el script con una cuenta con Lector de administración de costos.
- Filtra la columna Estado por "Sin presupuesto" y "Presupuesto sin alerta".
- Acuerda con finanzas un monto mensual por suscripción.
- Crea en Cost Management los presupuestos con avisos al 80 % y al 100 %, y vuelve a correr el reporte para confirmarlo.
Ver el código completo de get-suscripciones-sin-presupuesto.ps1
<#
.SYNOPSIS
¿Qué suscripciones no tienen presupuesto o no avisan cuando se pasan?
.DESCRIPTION
Revisa los presupuestos a nivel de suscripción y sus notificaciones activas. Una suscripción que no se pudo consultar queda marcada aparte, no como "sin presupuesto". Solo lectura: no crea presupuestos.
.PARAMETER Suscripciones
IDs o nombres de suscripción. Si se omite, usa todas a las que tienes acceso.
.PARAMETER RutaCsv
Ruta del CSV de salida. Si se omite, solo se muestra en pantalla.
.EXAMPLE
.\get-suscripciones-sin-presupuesto.ps1 -RutaCsv .\presupuestos.csv
.NOTES
Permisos: Lector de administración de costos (Cost Management Reader) o Lector (Reader) en cada suscripción
Rol mínimo: Lector de administración de costos (Cost Management Reader)
Licencia: Basta una suscripción de Azure; no requiere plan adicional.
Licencia del código: MIT. Se entrega TAL CUAL, sin garantía. Pruébalo primero en una suscripción de pruebas.
#>
[CmdletBinding()]
param(
[ValidateNotNullOrEmpty()][string[]]$Suscripciones,
[string]$RutaCsv
)
$ErrorActionPreference = 'Stop'
if (-not (Get-Command Connect-AzAccount -ErrorAction SilentlyContinue)) {
throw 'Falta el módulo. Instálalo con: Install-Module Az.Accounts -Scope CurrentUser'
}
if (-not (Get-AzContext)) { $null = Connect-AzAccount }
function InvocarAzure($Metodo, $Ruta, $Cuerpo) {
$parametros = @{ Method = $Metodo }
if ($Ruta -like 'http*') { $parametros.Uri = $Ruta } else { $parametros.Path = $Ruta }
if ($Cuerpo) { $parametros.Payload = $Cuerpo | ConvertTo-Json -Depth 10 }
# Invoke-AzRestMethod no lanza error con 4xx o 5xx. 429 es una limitación temporal: se reintenta.
for ($intento = 1; ; $intento++) {
$respuesta = Invoke-AzRestMethod @parametros
if ($respuesta.StatusCode -ne 429 -or $intento -ge 4) { break }
Start-Sleep -Seconds 30
}
if ($respuesta.StatusCode -ge 400) { throw "Azure respondió $($respuesta.StatusCode) en ${Ruta}: $($respuesta.Content)" }
if ($respuesta.Content) { $respuesta.Content | ConvertFrom-Json -AsHashtable }
}
function ListarPaginado($Ruta) {
while ($Ruta) {
$pagina = InvocarAzure GET $Ruta
$pagina.value
$Ruta = $pagina.nextLink
}
}
$activas = @(ListarPaginado '/subscriptions?api-version=2022-12-01' | Where-Object {
$_.state -notin 'Disabled', 'Deleted' -and
(-not $Suscripciones -or $_.subscriptionId -in $Suscripciones -or $_.displayName -in $Suscripciones)
})
if (-not $activas) { throw 'No hay suscripciones activas a las que tengas acceso con ese filtro.' }
$noEncontradas = @($Suscripciones | Where-Object { $_ -and $_ -notin $activas.subscriptionId -and $_ -notin $activas.displayName })
if ($noEncontradas) { throw "No se encontró o no está activa: $($noEncontradas -join ', '). Revisa -Suscripciones." }
$nombres = @{}
foreach ($s in $activas) { $nombres[$s.subscriptionId] = $s.displayName }
$filas = @(foreach ($s in $activas) {
try {
$presupuestos = @(ListarPaginado "/subscriptions/$($s.subscriptionId)/providers/Microsoft.CostManagement/budgets?api-version=2026-06-01")
$conAlerta = @($presupuestos | Where-Object {
$_.properties.notifications -and ($_.properties.notifications.GetEnumerator() | Where-Object { $_.Value.enabled })
})
$estado = if (-not $presupuestos) { 'Sin presupuesto' }
elseif (-not $conAlerta) { 'Presupuesto sin alerta' }
else { 'Con presupuesto y alerta' }
} catch {
Write-Warning "No se pudo consultar $($s.displayName): $_"
$presupuestos = @(); $conAlerta = @()
$estado = 'No se pudo consultar'
}
[pscustomobject]@{
Suscripcion = $s.displayName
SuscripcionId = $s.subscriptionId
Estado = $estado
Presupuestos = $presupuestos.Count
PresupuestosConAlerta = $conAlerta.Count
}
})
Write-Host "Suscripciones revisadas: $($filas.Count)"
$filas | Group-Object Estado | ForEach-Object { Write-Host " $($_.Name): $($_.Count)" }
if ($filas.Count -eq 0) {
Write-Host 'Sin resultados.'
# Sin esto, el CSV de una corrida anterior parecería el resultado de esta.
if ($RutaCsv) { Set-Content -Path $RutaCsv -Value '' -Encoding utf8BOM }
} elseif ($RutaCsv) {
# UTF-8 con BOM para que Excel en español muestre bien los acentos.
$filas | Export-Csv -Path $RutaCsv -NoTypeInformation -Encoding utf8BOM
Write-Host "CSV guardado en $RutaCsv"
} else {
$filas | Format-Table -AutoSize
}
# El reporte ya quedó escrito; el error avisa que está incompleto.
$sinConsultar = @($filas | Where-Object Estado -EQ 'No se pudo consultar')
if ($sinConsultar) { throw "Reporte incompleto, no se pudo consultar: $($sinConsultar.Suscripcion -join ', ')" }