Azure EA vs MCA Billing Scopes

The specific bottleneck this page solves: a pipeline built against an Enterprise Agreement (EA) billing scope breaks — silently in the worst case, with a flat 404 NotFound in the best — the moment an organization migrates to a Microsoft Customer Agreement (MCA), because the two agreement types expose structurally different billing-scope hierarchies under the same Microsoft.CostManagement/query endpoint. EA nests four levels deep as enrollment → department → account → subscription, addressed by a numeric enrollment number. MCA nests as billing account → billing profile → invoice section → subscription, addressed by an opaque GUID-composite billing account ID. Code that hard-codes billingAccounts/{enrollmentNumber}/enrollmentAccounts/{id} has no equivalent path under MCA — that segment simply does not exist — and code that assumes a numeric billing account ID will fail to even URL-encode an MCA identifier correctly. This is the scope-resolution layer that sits underneath Azure Cost Management Setup: get the scope string wrong and every downstream query, export, and reconciliation job in that pipeline returns nothing or the wrong slice of spend.

The migration itself is not optional for most large customers. Microsoft has been moving EA customers to MCA at contract renewal since 2019, and many EA enrollments transition mid-cycle for procurement reasons unrelated to engineering. A pipeline that only ever tested against one agreement type is a pipeline that has not yet met its second billing model.

Root Cause & Failure Modes

The root cause is that EA and MCA are not two flavors of the same API — they are two distinct billing account types (Enterprise vs MicrosoftCustomerAgreement) surfaced through the Microsoft.Billing resource provider, and the Microsoft.CostManagement/query scope parameter must be built from the correct type’s resource ID shape or the request is rejected before any cost data is evaluated.

Three failure patterns show up repeatedly:

  • Hard-coded EA path segments. Code written as billingAccounts/{enrollmentNumber}/departments/{departmentId} returns 404 InvalidBillingAccountId under MCA, because MCA has no departments resource — the closest analog is an invoice section under a billing profile, which is a different depth and a different role model entirely.
  • Treating the billing account ID as always numeric. The EA enrollment number is a short numeric string (commonly 6–8 digits). The MCA billing account ID is a composite identifier — two GUIDs joined by an underscore-delimited suffix, for example a1b2c3d4-.../98765432-..._2019-05-31. Regex validation or string-length assumptions written against the EA shape reject every legitimate MCA scope.
  • Assuming one role grants read access everywhere. Enterprise Reader (EA) and Billing Account Reader (MCA) are not interchangeable role definitions — they attach to different resource types, so a service principal provisioned for EA has zero effective permission on an MCA scope even if an administrator “added the same role name” in the portal.

The practical trigger is almost always a mid-quarter contract event: procurement renews under MCA, the old EA enrollment is placed in a 60-day read-only wind-down, and every automated job pointed at the EA scope starts failing at the exact moment finance needs continuity most. Detecting the agreement type programmatically — rather than assuming it from a config value that nobody updated — is what keeps the pipeline resilient across that transition.

EA vs MCA Scope Comparison

Dimension EA (Enterprise Agreement) MCA (Microsoft Customer Agreement)
Scope hierarchy Enrollment → Department → Enrollment Account → Subscription Billing Account → Billing Profile → Invoice Section → Subscription
Root identifier Numeric enrollment number (e.g. 8455814) Composite GUID string (e.g. a1b2.../98765..._2019-05-31)
Root API scope /providers/Microsoft.Billing/billingAccounts/{enrollmentNumber} /providers/Microsoft.Billing/billingAccounts/{billingAccountId}
Mid-tier scope .../departments/{departmentId} .../billingProfiles/{billingProfileId}
Leaf tier scope .../enrollmentAccounts/{enrollmentAccountId} .../billingProfiles/{id}/invoiceSections/{invoiceSectionId}
Required role Enterprise Administrator / Department Administrator (read variants) Billing Account Reader / Billing Profile Reader / Invoice Section Reader
Auth model Azure AD RBAC via Cost Management; legacy consumption.azure.com API key path retired Azure AD RBAC only — no API-key fallback ever existed
Amortized cost availability Supported at enrollment and department scope since general availability Supported at billing-profile scope and below; invoice-section-only credentials can miss reservation-linked amortization until the profile grants visibility
Invoice grain One consolidated invoice per enrollment, monthly One invoice per billing profile; a billing account can hold several profiles with independent invoices

The query URL shape is identical across both — https://management.azure.com/{scope}/providers/Microsoft.CostManagement/query?api-version=2023-11-01 — which is precisely what makes the bug subtle: the request looks structurally valid in both cases, and the failure surfaces as an authorization or not-found error rather than a schema error, so it is easy to misdiagnose as a permissions problem when the scope string itself is wrong.

Production Pipeline Architecture

Treat agreement-type resolution as an explicit step, not an assumption baked into a config file. A pipeline resilient to an EA→MCA migration runs four phases:

  1. Type detection. Query GET /providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01 once at startup (or on a daily cache refresh) and read the agreementType field on the response — EnterpriseAgreement or MicrosoftCustomerAgreement — rather than trusting a static environment variable that survives contract renewals unedited.
  2. Scope construction. Branch on the detected type and build the provider path from the corresponding identifiers, as detailed in the comparison table above. This is the layer the adapter below implements.
  3. Query execution. POST the same Microsoft.CostManagement/query body regardless of agreement type — the request schema itself (type, timeframe, dataset.aggregation, dataset.grouping) is shared, which is why teams underestimate how different the scope half of the contract is.
  4. Tag and rollup handoff. Route the normalized response into the same downstream tag-resolution stage. If the org is EA, that stage is Mapping Azure EA Billing to FinOps Tags; MCA’s cleaner tag propagation generally needs less fallback inheritance, but the same Unallocated default should apply so reporting stays consistent across a mixed EA/MCA estate during migration.

This detection-first design is also what keeps the acquisition layer compatible with the broader ingestion engine in Azure Cost Management API Integration — that page’s pagination and deduplication logic operates on the query response, which is agreement-agnostic, so isolating agreement-specific logic to scope construction keeps the rest of the pipeline provider-shape-independent.

Step-by-Step Python Implementation

The adapter below detects nothing on its own — it takes an explicit AgreementType and the relevant identifiers, builds the correct scope path, and executes the query with retry handling for the same 429/5xx conditions documented in the parent setup guide. Keeping detection and construction as separate concerns makes the scope-builder trivially unit-testable without a live Azure AD token.

import os
import time
import logging
from dataclasses import dataclass
from enum import Enum
from typing import Any, Dict, Optional

import requests
from azure.identity import DefaultAzureCredential

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

COST_MGMT_BASE = "https://management.azure.com"
API_VERSION = "2023-11-01"
ARM_SCOPE = "https://management.azure.com/.default"


class AgreementType(Enum):
    EA = "EnterpriseAgreement"
    MCA = "MicrosoftCustomerAgreement"


@dataclass
class BillingScope:
    """Identifiers needed to build a Cost Management scope path for either agreement type.

    Only the fields relevant to the target agreement_type need to be populated;
    the builder ignores fields that don't apply to the chosen path depth.
    """
    agreement_type: AgreementType
    billing_account_id: str            # EA enrollment number, or MCA composite billing account ID
    department_id: Optional[str] = None        # EA only
    enrollment_account_id: Optional[str] = None  # EA only
    billing_profile_id: Optional[str] = None     # MCA only
    invoice_section_id: Optional[str] = None     # MCA only

    def to_scope_path(self) -> str:
        """Build the /providers/Microsoft.Billing/billingAccounts/... scope segment.

        Raises ValueError rather than silently falling back, because a wrong
        scope string fails downstream with a confusing 404, not a clear error.
        """
        root = f"providers/Microsoft.Billing/billingAccounts/{self.billing_account_id}"

        if self.agreement_type is AgreementType.EA:
            if self.enrollment_account_id:
                return f"{root}/enrollmentAccounts/{self.enrollment_account_id}"
            if self.department_id:
                return f"{root}/departments/{self.department_id}"
            return root

        if self.agreement_type is AgreementType.MCA:
            if not self.billing_profile_id:
                # MCA billing-account-root queries are valid but rarely what you want:
                # they require Billing Account Reader, a broader grant than most
                # automation service principals hold.
                return root
            if self.invoice_section_id:
                return (
                    f"{root}/billingProfiles/{self.billing_profile_id}"
                    f"/invoiceSections/{self.invoice_section_id}"
                )
            return f"{root}/billingProfiles/{self.billing_profile_id}"

        raise ValueError(f"Unsupported agreement_type: {self.agreement_type}")


class CostManagementScopeClient:
    """Executes Microsoft.CostManagement/query against an agreement-specific scope."""

    def __init__(self, scope: BillingScope, max_retries: int = 5):
        self.scope_path = scope.to_scope_path()
        self.credential = DefaultAzureCredential()
        self.session = requests.Session()
        self.max_retries = max_retries

    def _headers(self) -> Dict[str, str]:
        token = self.credential.get_token(ARM_SCOPE).token
        return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}

    def query_cost(self, start: str, end: str, metric: str = "ActualCost") -> Dict[str, Any]:
        """Run a Custom-timeframe cost query at the resolved scope, retrying on throttling."""
        url = f"{COST_MGMT_BASE}/{self.scope_path}/providers/Microsoft.CostManagement/query?api-version={API_VERSION}"
        payload = {
            "type": metric,
            "timeframe": "Custom",
            "timePeriod": {"from": start, "to": end},
            "dataset": {
                "granularity": "Daily",
                "aggregation": {"totalCost": {"name": "PreTaxCost", "function": "Sum"}},
            },
        }

        for attempt in range(self.max_retries):
            response = self.session.post(url, headers=self._headers(), json=payload, timeout=60)
            if response.status_code == 429:
                retry_after = int(response.headers.get("Retry-After", 30))
                logger.warning("Throttled on scope %s. Waiting %ds.", self.scope_path, retry_after)
                time.sleep(retry_after)
                continue
            if response.status_code == 404:
                # Almost always a scope-shape mismatch, not a genuine missing resource —
                # surface the path so the wrong agreement-type assumption is obvious.
                raise RuntimeError(f"Scope not found — check agreement type for: {self.scope_path}")
            if response.status_code >= 500:
                backoff = min(2 ** attempt, 30)
                logger.warning("Server error %d, backing off %ds.", response.status_code, backoff)
                time.sleep(backoff)
                continue
            response.raise_for_status()
            return response.json()

        raise RuntimeError(f"Max retries exceeded querying scope {self.scope_path}")


if __name__ == "__main__":
    # EA example: enrollment root, no department/account narrowing.
    ea_scope = BillingScope(
        agreement_type=AgreementType.EA,
        billing_account_id=os.getenv("EA_ENROLLMENT_NUMBER", "8455814"),
    )
    logger.info("EA scope path: %s", ea_scope.to_scope_path())

    # MCA example: a specific billing profile's invoice section.
    mca_scope = BillingScope(
        agreement_type=AgreementType.MCA,
        billing_account_id=os.getenv("MCA_BILLING_ACCOUNT_ID", "a1b2c3d4-0000-0000-0000-000000000000_2019-05-31"),
        billing_profile_id=os.getenv("MCA_BILLING_PROFILE_ID", "PE2U-XXXX-XXX-XXX"),
        invoice_section_id=os.getenv("MCA_INVOICE_SECTION_ID"),
    )
    logger.info("MCA scope path: %s", mca_scope.to_scope_path())

    client = CostManagementScopeClient(scope=mca_scope)
    result = client.query_cost(start="2026-06-15", end="2026-07-15")
    logger.info("Rows returned: %d", len(result.get("properties", {}).get("rows", [])))

The to_scope_path method is the entire adapter contract: everything above it is detection or configuration, everything below it is a generic Microsoft.CostManagement/query call that does not care which agreement produced the scope string.

Verification & Testing

  • Unit-test the scope builder in isolation. BillingScope.to_scope_path() takes no network dependency, so assert the exact string for each of the six combinations in the comparison table (EA root/department/account, MCA root/profile/invoice-section) against known-good values captured from the Azure portal’s “Properties” blade.
  • Assert on the 404-vs-403 distinction. A wrong scope shape returns 404; a right scope shape with insufficient role assignment returns 403. Log and alert on these differently — conflating them sends engineers hunting for a missing IAM grant when the actual bug is a stale EA-shaped scope string post-migration.
  • Cross-check against the billing account list. Call GET .../billingAccounts?api-version=2024-04-01 and confirm the agreementType field matches what your configuration assumes before running a full reconciliation — a five-line guard that catches the single most common cause of this failure class.
  • Dry-run both scopes during the migration window. While an EA enrollment is in its read-only wind-down, both the old EA scope and the new MCA scope are queryable; run parallel queries and diff totals to confirm the MCA scope is capturing 100% of what the EA scope reported before cutting the old path over.

Common Pitfalls Checklist

  • Hard-coding enrollmentAccounts or departments for all customers. Those segments don’t exist under MCA — branch on agreement_type and build the scope with the method above rather than a single fixed template.
  • Assuming the billing account ID is always numeric. MCA IDs are GUID-composite strings; validation logic written for EA’s short numeric enrollment number will reject every legitimate MCA scope.
  • Reusing an EA service principal’s role assignment after migration. Enterprise Reader grants nothing on an MCA billing account — provision Billing Profile Reader (or Invoice Section Reader for narrower scope) explicitly post-migration.
  • Treating a 404 as a permissions problem. It is almost always a scope-shape mismatch; a 403 is the permissions signal. Distinguish them in logging, as the adapter does.
  • Querying MCA at the billing-account root by default. It works but demands the broadest role (Billing Account Reader); prefer scoping to the specific billing profile or invoice section a pipeline actually needs, matching least-privilege practice.

Frequently Asked Questions

How do I detect whether a subscription is billed under EA or MCA?

Call GET /providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01 with the caller’s credentials and read the properties.agreementType field on each returned billing account — it is literally EnterpriseAgreement or MicrosoftCustomerAgreement. Cache this once daily rather than assuming a config value stays accurate across a contract renewal.

Why does my Cost Management query return 404 after a migration?

A 404 from Microsoft.CostManagement/query almost always means the scope path shape no longer matches the billing account’s actual agreement type — for example, an enrollmentAccounts segment sent against an MCA billing account, which has no such resource. It is not usually a permissions error; a permissions error returns 403 instead. Rebuild the scope with BillingScope.to_scope_path() using the detected agreement_type.

Does amortized cost data work the same way under both agreement types?

Both support the AmortizedCost query type, but the scope you need it at differs: EA exposes amortized figures cleanly at the enrollment and department scope, while MCA generally requires querying at or below the billing-profile scope for reservation-linked amortization to resolve fully — an invoice-section-only credential can under-report it. Validate amortized totals against the enrollment/profile-level query before trusting a narrower scope’s numbers.

Can I query an EA scope and an MCA scope from the same pipeline run?

Yes, and during a migration window you usually should — the EA enrollment typically stays queryable read-only for a wind-down period while the new MCA billing profile becomes authoritative. Run both BillingScope instances through the same CostManagementScopeClient, since the query body and response schema are identical; only the scope path differs.

Up: Azure Cost Management Setup