# ADR: What Azure charges

Status: accepted, 2026-09-30, shipped as 1.0.3.55. Steve asked for the portal's subscription overview on the Admin tab, in public, since the bill is nothing to hide. A reader who sees how fast the site answers should see what it costs to run in the same place.

## In plain words

This page puts the site's real Azure bill on the Admin tab for anyone to see: spending over time with Azure's forecast, and the cost broken down by resource and by type. The site's own identity asks the billing service (Azure Cost Management) once an hour and holds no key.

What that is worth: a developer sees what each part of the site costs beside how fast it answers, and the organization can show its running cost in public without exposing its subscription id.

## Context

The Azure portal opens a subscription on four cards: the spending rate with a forecast, the cost by resource, the top resource types, and Defender coverage. The Admin tab already showed the milliseconds (the traffic, timing and machines cards) and the request units the document store charged, and the dollar figure lived only in the portal and on the Performance page, where it was typed from a price list on 20 September.

Read live on 30 September, with the subscription just renamed Stout-PayAsYouGo and its reactivation finished at about 15:30 CDT:

| Reading | Value | Where it was read |
| --- | --- | --- |
| Month to date | $47.18, every cent of it in RG-THEYARD-SS | Cost Management, grouped by resource group, 16:09 CDT |
| Forecast for September | $49.03: $45.85 billed through 28 September and $3.18 forecast for the last two days | Cost Management's forecast, 16:09 CDT, the same figure the portal showed at 15:36 |
| Rows for 35 days by resource | 198, one page, four columns: Cost, UsageDate, ResourceId, Currency | Cost Management, 16:09 CDT |
| Newest day | 30 September, $0.48 so far | the same read |

The month to date is more than the $22.38 list price on the Performance page because the two container groups the plan replaced ran until 20 September, and September's bill carries them.

## Decision

The first three portal cards, on one workbench card called What Azure charges, under "Is it costing anything?": the spend over the window as a running total with Azure's forecast dashed to the month's end, the cost by resource as a donut that names the top four and folds the rest into Others, and the resources by type as bars. Public, like the rest of the tab, with no operator key. Defender coverage is left out, because it describes the subscription's security and this card is about its bill.

- **Who asks.** The site's own identity, `id-theyard-ss`, the one that already reads Application Insights (ADR-024) and its own resource. It asks for a management token the same way, from whichever door the host has, and holds no key (ADR-072). It needed one role, Cost Management Reader on the subscription, which is free and adds nothing to the bill; Steve assigned it on 30 September and the probe read it back at 16:09.
- **When it asks.** Once an hour per site, from a background recorder, never on a request. Cost Management is rate limited and reports a day eight to twenty four hours late, so asking on every open of the card would spend the allowance on the same answer. The first read is half a minute after the site starts, so a roll costs one read and nothing else.
- **What it asks.** Two calls. The actual cost, daily, grouped by resource, for the last thirty-five days: one call answers the line and the donut, and thirty-five days is the month window and five more, because Azure revises a day after it ends. And the forecast for the month, which answers with the days Azure counts as billed and the days it forecasts; added up they are the month's forecast, the portal's own figure, and the days to come after the newest reported day are the dashed line. The resource types are read off the resource paths in the first answer, so the bars need no second role and no third call.
- **What leaves the process.** A resource's name and type, a day and a cost. The resource path carries the subscription's id, so it is cut down in the one method that reads the answer and never reaches the wire. A refusal is named for what it means (no role yet, slow down, the billing account is still being set up) and never quotes the service's own error, which names the scope it refused.
- **Where the read is held.** In memory, the last read only. The handoff for this change proposed a Cosmos DB container. Azure Cost Management is already the record of the bill and keeps it for the life of the subscription, so a copy here would be a second truth that could only drift from the first; the repository's first rule is to derive rather than store. What a roll loses is an hour's read, taken again thirty seconds later. The port the recorder writes through is the same shape a store would take, so a kept history is one adapter away if a year of bills is ever wanted on the card.
- **The window.** The same buttons as every other chart on the tab (ADR: The Admin tab, as a product). Azure bills by the day, so the day is the smallest grain: the last day is two days (yesterday whole, today as far as Azure has reported it), a week is seven and a month thirty, and the hour the other charts offer is shown as the last day, with a sentence that says so. The forecast is drawn on the month window, where its end is inside the chart.
- **The newest day.** Marked as still being added to while it is today or yesterday, on the line and in a sentence, because a total that is still moving should look like one.
- **An absent reading.** A sentence, never $0.00: off Azure ("a local run reads nothing"), before the first read, and without the role. A card that drew a zero for "no role yet" would be a false statement about the bill.

Money on this card is dollars and cents as a `double`, rounded to the cent. The repository's rule that money is whole dollars as `int` is about the auction, whose dataset prices are whole dollars and whose rules add whole-dollar increments. A bill is counted in cents.

## What holds it

- `CostTests`: a fixed Cost Management answer, with its columns out of the order the code asks for, becomes the expected days with no subscription path left in them; a nested type reads whole (a database is `microsoft.sql/servers/databases`); the forecast answer keeps each day marked billed or forecast by Azure's own status column; the donut folds past four; the newest day is flagged, a quiet day before it is a zero and a day after it is not drawn; each absent state is a sentence with nothing drawn; a refusal never quotes the scope; the held read is replaced whole; the reader's minute of cache holds; off Azure the recorder asks nobody; and the endpoint answers every window, and a window it does not know as the month.
- `PageStatusTests`: the page sweep reads `/api/admin/costs?window=30d` at every roll, because a card that throws is a page that is down (ADR: Every page, checked at every roll).
- `spend.test.ts`: the hour shown as the day, money as the bill writes it, the headline sentences, the axis's round top, the line turning to dashes where the reported days end, the donut's shares and the bars' lengths.
- `admin.spec.ts`: with an answer in hand, the card draws the line, the donut with its legend, and the bars.

The shaping, the one method a subscription path passes through:

```csharp
/// <summary>
/// A page of actual cost, as the days the store keeps. Cost Management
/// answers with columns and rows, and the columns are read by name, never
/// by position, because the order is the service's to change. The resource
/// path is cut to its name and type here, so the subscription's id never
/// leaves this method, let alone reaches the wire.
/// </summary>
public static IReadOnlyList<CostDay> DaysFrom(JsonElement properties)
{
    var columns = ColumnsOf(properties);
    int cost = Find(columns, "Cost", "PreTaxCost", "CostUSD");
    int date = Find(columns, "UsageDate");
    int resource = Find(columns, "ResourceId");
    int currency = Find(columns, "Currency", "BillingCurrency");
    if (cost < 0 || date < 0)
    {
        return [];
    }

    var days = new List<CostDay>();
    foreach (var row in RowsOf(properties))
    {
        string? day = DayOf(row[date]);
        if (day is null)
        {
            continue;
        }

        var (name, type) = ResourceOf(resource < 0 ? null : row[resource].GetString());
        days.Add(new CostDay(day, name, type, Number(row[cost]), currency < 0 ? "USD" : row[currency].GetString() ?? "USD"));
    }

    // Two rows for one resource on one day are one charge: a grouped query
    // should not return them, and the cards count each pair once.
    return days
        .GroupBy(day => (day.Day, day.Resource, day.Type, day.Currency))
        .Select(group => new CostDay(group.Key.Day, group.Key.Resource, group.Key.Type, Math.Round(group.Sum(day => day.Cost), 4), group.Key.Currency))
        .OrderBy(day => day.Day, StringComparer.Ordinal)
        .ThenBy(day => day.Resource, StringComparer.Ordinal)
        .ToList();
}

/// <summary>A page of forecast: every day the answer carries, each marked billed or forecast by the service's own status column.</summary>
public static IReadOnlyList<CostForecastDay> ForecastFrom(JsonElement properties)
{
    var columns = ColumnsOf(properties);
    int cost = Find(columns, "Cost", "PreTaxCost", "CostUSD");
    int date = Find(columns, "UsageDate");
    int status = Find(columns, "CostStatus");
    int currency = Find(columns, "Currency", "BillingCurrency");
    if (cost < 0 || date < 0)
    {
        return [];
    }

    return RowsOf(properties)
        .Select(row => (
            Day: DayOf(row[date]),
            Cost: Number(row[cost]),
            Currency: currency < 0 ? "USD" : row[currency].GetString() ?? "USD",
            Billed: status >= 0 && string.Equals(row[status].GetString(), "Actual", StringComparison.OrdinalIgnoreCase)))
        .Where(row => row.Day is not null)
        .GroupBy(row => (row.Day!, row.Currency, row.Billed))
        .Select(group => new CostForecastDay(group.Key.Item1, Math.Round(group.Sum(row => row.Cost), 4), group.Key.Currency, group.Key.Billed))
        .OrderBy(day => day.Day, StringComparer.Ordinal)
        .ToList();
}

/// <summary>
/// A resource path as a name and a type. The type is every type segment
/// after the last provider, so a database is microsoft.sql/servers/databases
/// and not a server; the name is the last segment. A charge with no
/// resource (a tax line, a marketplace line) is kept under a name that says
/// so rather than dropped, because it is still on the bill.
/// </summary>
public static (string Name, string Type) ResourceOf(string? resourceId)
{
    if (string.IsNullOrWhiteSpace(resourceId))
    {
        return ("not tied to a resource", "other");
    }

    string path = resourceId.Trim().ToLowerInvariant();
    int provider = path.LastIndexOf("/providers/", StringComparison.Ordinal);
    if (provider < 0)
    {
        return (path.TrimEnd('/').Split('/')[^1], "other");
    }

    string[] parts = path[(provider + "/providers/".Length)..].Split('/', StringSplitOptions.RemoveEmptyEntries);
    if (parts.Length < 3)
    {
        return (parts.Length == 0 ? "not tied to a resource" : parts[^1], "other");
    }

    // namespace, then type and name in pairs: the types are the odd places.
    var types = new List<string> { parts[0] };
    for (int i = 1; i < parts.Length; i += 2)
    {
        types.Add(parts[i]);
    }

    return (parts[^1], string.Join('/', types));
}

/// <summary>
/// Each column's place in a row, by the column's name in any case, so a row is read by name and
/// not by position.
/// </summary>
private static Dictionary<string, int> ColumnsOf(JsonElement properties) =>
    properties.TryGetProperty("columns", out var columns)
        ? columns.EnumerateArray()
            .Select((column, index) => (Name: column.GetProperty("name").GetString() ?? "", Index: index))
            .GroupBy(column => column.Name, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(group => group.Key, group => group.First().Index, StringComparer.OrdinalIgnoreCase)
        : new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);

/// <summary>The rows of an answer, or none when the answer carries no row array.</summary>
private static IEnumerable<JsonElement> RowsOf(JsonElement properties) =>
    properties.TryGetProperty("rows", out var rows) && rows.ValueKind == JsonValueKind.Array
        ? rows.EnumerateArray()
        : [];

/// <summary>
/// The place of the first of <paramref name="names"/> the answer has, or -1 when it has none of
/// them.
/// </summary>
private static int Find(Dictionary<string, int> columns, params string[] names)
{
    foreach (string name in names)
    {
        if (columns.TryGetValue(name, out int index))
        {
            return index;
        }
    }

    return -1;
}

/// <summary>
/// A cost cell as a number; the service sends a number or a numeric string, and anything else
/// counts as zero.
/// </summary>
private static double Number(JsonElement value) => value.ValueKind switch
{
    JsonValueKind.Number => value.GetDouble(),
    JsonValueKind.String when double.TryParse(value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) => parsed,
    _ => 0,
};

/// <summary>The service writes a day as the number 20260930 in one answer and a date string in another; both are read.</summary>
private static string? DayOf(JsonElement value)
{
    string text = value.ValueKind == JsonValueKind.Number ? value.GetInt64().ToString(CultureInfo.InvariantCulture) : value.GetString() ?? "";
    if (text.Length == 8 && DateOnly.TryParseExact(text, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var compact))
    {
        return compact.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
    }

    return text.Length >= 10 && DateOnly.TryParseExact(text[..10], "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var dashed)
        ? dashed.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
        : null;
}
```
*Live from [`api/TheYard.Api/Costs.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Costs.cs#L67-L230), region `cost-shape`, read from this build at 1d6b50b.*

The recorder, which is the only caller of Azure:

```csharp
/// <summary>
/// Reads Cost Management once an hour and holds the answer (ADR: What Azure
/// charges). Never on a request: the service is rate limited, lags the day by
/// eight to twenty four hours, and a card that asked it on every open would
/// spend the allowance on the same answer. Thirty-five days each time, the
/// month window and five more, because a day is revised after it ends and the
/// next read is how the revision lands.
/// </summary>
public sealed class CostRecorder(
    CostReader reader,
    ICostHistory history,
    CostStatus status,
    TimeProvider clock,
    ILogger<CostRecorder> logger) : BackgroundService
{
    /// <summary>How often the bill is read.</summary>
    public static readonly TimeSpan Every = TimeSpan.FromHours(1);

    /// <summary>How soon a read that did not finish is tried again.</summary>
    public static readonly TimeSpan Retry = TimeSpan.FromMinutes(5);

    // #region cost-retry
    /// <summary>
    /// How long to wait after a read. An hour after one that went through,
    /// and after one Azure refused: a missing role does not appear in five
    /// minutes, and a request to slow down is a request to slow down. Five
    /// minutes after one that did not finish, because a timeout says nothing
    /// about the next answer, and an hour of an empty card for one slow answer
    /// is the wrong trade (ADR: What Azure charges, addendum).
    /// </summary>
    public static TimeSpan WaitAfter(CostOutcome outcome) => outcome == CostOutcome.Failed ? Retry : Every;
    // #endregion cost-retry

    /// <summary>One read, public so the suite can run one without waiting an hour; says how it went.</summary>
    public async Task<CostOutcome> RecordOnceAsync(CancellationToken cancellation)
    {
        var now = clock.GetUtcNow();
        if (!reader.Configured)
        {
            status.Set(now, false, CostReader.NotConfigured);
            return CostOutcome.Failed;
        }

        var availability = await history.AvailabilityAsync(cancellation);
        if (!availability.Available)
        {
            status.Set(now, false, availability.Reason);
            return CostOutcome.Failed;
        }

        var today = DateOnly.FromDateTime(now.UtcDateTime);
        var read = await reader.ReadAsync(today.AddDays(-(CostWindows.ReadDays - 1)), today, cancellation);
        if (read.Outcome == CostOutcome.Read)
        {
            await history.KeepAsync(read.Days, read.Forecast, cancellation);
        }

        status.Set(now, read.Outcome == CostOutcome.Read, read.Note);
        return read.Outcome;
    }

    /// <summary>
    /// The loop: half a minute after start, then a read, then a wait set by how the read went.
    /// A run that is not on Azure stops after its first read, since there is nothing to ask with.
    /// </summary>
    protected override async Task ExecuteAsync(CancellationToken stopping)
    {
        // Half a minute after the start, so the first read never competes
        // with the catalogue this process loads as it starts.
        await Task.Delay(TimeSpan.FromSeconds(30), stopping).ContinueWith(_ => { }, TaskScheduler.Default);
        while (!stopping.IsCancellationRequested)
        {
            var outcome = CostOutcome.Failed;
            try
            {
                outcome = await RecordOnceAsync(stopping);
                if (!reader.Configured)
                {
                    return;
                }
            }
            catch (OperationCanceledException) when (stopping.IsCancellationRequested)
            {
                return;
            }
            catch (Exception ex)
            {
                // The type, never the message: a message from the identity
                // endpoint or the service names the resource it refused.
                logger.LogWarning("The bill could not be read ({Exception})", ex.GetType().Name);
                status.Set(clock.GetUtcNow(), false, $"the last read did not finish ({ex.GetType().Name}); the next is in five minutes");
            }

            await Task.Delay(WaitAfter(outcome), stopping).ContinueWith(_ => { }, TaskScheduler.Default);
        }
    }
}
```
*Live from [`api/TheYard.Api/CostRecorder.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/CostRecorder.cs#L9-L105), region `cost-recorder`, read from this build at 1d6b50b.*

The view the cards are drawn from, and the cache in front of it:

```csharp
/// <summary>
/// The last read, shaped for the three cost cards (ADR: What Azure charges):
/// the spend over the window with the forecast to the month's end, and what
/// each resource and each type of resource cost, in the window and as a month
/// at the window's rate. Pure, so a fixed set of days in gives a fixed answer
/// out and a test can hold it.
/// </summary>
public static class CostView
{
    /// <summary>How many resources the donut names before it folds the rest into Others.</summary>
    public const int Named = 4;

    /// <summary>
    /// The cards' answer for one window: the spend line with its forecast, the month so far,
    /// and the resources and types. With no days kept, or no store to keep them in, the answer
    /// says why in a sentence instead.
    /// </summary>
    public static CostReport Build(string? window, CostKept kept, DateTimeOffset now, CostHistoryAvailability availability, (DateTimeOffset? At, bool Read, string? Note) last)
    {
        var (name, length) = CostWindows.Parse(window);
        string today = now.UtcDay;
        string first = now.AddDays(-(length - 1)).UtcDay;
        string month = CostWindows.MonthOf(today);
        string currency = kept.Days.FirstOrDefault()?.Currency ?? kept.Forecast.FirstOrDefault()?.Currency ?? "USD";
        long? readAt = last.At?.ToUnixTimeMilliseconds();

        if (!availability.Available || kept.Days.Count == 0)
        {
            // Absent is said in a sentence, never drawn as a zero: a card that
            // showed $0.00 for "no role yet" would be a false statement about the bill.
            string note = !availability.Available
                ? availability.Reason
                : last.Note ?? (last.At is null ? "the first hour's read has not landed yet; the recorder reads Azure half a minute after the site starts" : "Azure reported no charges for this subscription yet");
            return CostReport.Absent(name, note, currency, readAt);
        }

        string newest = kept.Days.Max(day => day.Day)!;
        // Azure lags the day by eight to twenty four hours, so the newest day
        // it has reported is still being added to until it is two days old.
        bool partial = string.CompareOrdinal(newest, now.AddDays(-1).UtcDay) >= 0;

        var inWindow = kept.Days.Where(day => string.CompareOrdinal(day.Day, first) >= 0 && string.CompareOrdinal(day.Day, today) <= 0).ToList();
        var byDay = inWindow.GroupBy(day => day.Day).ToDictionary(group => group.Key, group => group.Sum(day => day.Cost), StringComparer.Ordinal);

        // Every day from the window's first to the newest Azure has reported:
        // a day before the newest with no rows cost nothing, and a day after
        // it has not been reported, so it is left off rather than drawn as zero.
        var points = new List<CostPoint>();
        double running = 0;
        for (var day = DateOnly.ParseExact(first, "yyyy-MM-dd", CultureInfo.InvariantCulture); ; day = day.AddDays(1))
        {
            string key = day.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
            if (string.CompareOrdinal(key, newest) > 0 || string.CompareOrdinal(key, today) > 0)
            {
                break;
            }

            double cost = byDay.GetValueOrDefault(key);
            running += cost;
            points.Add(new CostPoint(key, Money(cost), Money(running), partial && key == newest));
        }

        double monthToDate = kept.Days.Where(day => CostWindows.MonthOf(day.Day) == month).Sum(day => day.Cost);
        // The month's forecast is the forecast answer added up, billed days and
        // days to come, which is the portal's own figure. The dashed line is
        // only the days to come after the newest day the bill has reported.
        var answer = kept.Forecast.Where(day => CostWindows.MonthOf(day.Day) == month).ToList();
        var ahead = answer
            .Where(day => !day.Billed && string.CompareOrdinal(day.Day, newest) > 0)
            .OrderBy(day => day.Day, StringComparer.Ordinal)
            .ToList();
        var forecast = new List<CostPoint>();
        double projected = running;
        foreach (var day in ahead)
        {
            projected += day.Cost;
            forecast.Add(new CostPoint(day.Day, Money(day.Cost), Money(projected), false));
        }

        double? forecastMonth = answer.Count > 0 ? Money(answer.Sum(day => day.Cost)) : null;
        var rate = MonthRate.For(points, today);

        return new CostReport(
            name,
            CostWindows.Names,
            true,
            last.Read ? null : last.Note,
            currency,
            readAt,
            newest,
            partial,
            month,
            Money(monthToDate),
            forecastMonth,
            Money(inWindow.Sum(day => day.Cost)),
            rate.BilledDays,
            rate.MonthDays,
            points,
            forecast,
            Slices(inWindow, rate),
            Kinds(inWindow, rate));
    }

    /// <summary>
    /// What each resource cost in the window and what that comes to in a month,
    /// largest first, the top four by name and the rest as Others.
    /// </summary>
    public static IReadOnlyList<CostSlice> Slices(IReadOnlyList<CostDay> days, MonthRate rate)
    {
        double total = days.Sum(day => day.Cost);
        var ranked = days
            .GroupBy(day => (day.Resource, day.Type))
            .Select(group => (group.Key.Resource, group.Key.Type, Cost: group.Sum(day => day.Cost), Monthly: rate.Of(group)))
            .Where(entry => entry.Cost > 0)
            .OrderByDescending(entry => entry.Cost)
            .ThenBy(entry => entry.Resource, StringComparer.Ordinal)
            .ToList();

        var slices = ranked.Take(Named)
            .Select(entry => new CostSlice(entry.Resource, entry.Type, Money(entry.Cost), Money(entry.Monthly), Share(entry.Cost, total), 1))
            .ToList();
        var rest = ranked.Skip(Named).ToList();
        if (rest.Count > 0)
        {
            double others = rest.Sum(entry => entry.Cost);
            double othersMonthly = rest.Sum(entry => entry.Monthly);
            slices.Add(new CostSlice("Others", "others", Money(others), Money(othersMonthly), Share(others, total), rest.Count));
        }

        return slices;
    }

    /// <summary>
    /// What each type of resource cost in the window, what that comes to in a
    /// month, and how many of it are on the bill, the costliest first. A type the
    /// bill carries at $0.00, such as App Service apps whose plan pays for them,
    /// goes last, so the longest bar is where the money went.
    /// </summary>
    public static IReadOnlyList<CostKind> Kinds(IReadOnlyList<CostDay> days, MonthRate rate) =>
        days
            .GroupBy(day => day.Type)
            .Select(group => new CostKind(
                group.Key,
                LabelOf(group.Key),
                group.Select(day => day.Resource).Distinct(StringComparer.Ordinal).Count(),
                Money(group.Sum(day => day.Cost)),
                Money(rate.Of(group))))
            .OrderByDescending(kind => kind.Cost)
            .ThenByDescending(kind => kind.Resources)
            .ThenBy(kind => kind.Label, StringComparer.Ordinal)
            .ToList();

    /// <summary>A resource type in the words the portal uses for it; a type this list does not know is shown as Azure names it.</summary>
    public static string LabelOf(string type) => type switch
    {
        "microsoft.web/sites" => "App Service apps",
        "microsoft.web/serverfarms" => "App Service plans",
        "microsoft.containerinstance/containergroups" => "Container instances",
        "microsoft.containerregistry/registries" => "Container registries",
        "microsoft.sql/servers/databases" => "SQL databases",
        "microsoft.sql/servers" => "SQL servers",
        "microsoft.documentdb/databaseaccounts" => "Azure Cosmos DB accounts",
        "microsoft.insights/components" => "Application Insights",
        "microsoft.operationalinsights/workspaces" => "Log Analytics workspaces",
        "microsoft.communication/communicationservices" => "Communication Services",
        "microsoft.communication/emailservices" => "Email Communication Services",
        "microsoft.storage/storageaccounts" => "Storage accounts",
        "microsoft.managedidentity/userassignedidentities" => "Managed identities",
        "other" => "Not tied to a resource",
        _ => type,
    };

    /// <summary>Money to the cent, which is what the bill shows.</summary>
    public static double Money(double value) => Math.Round(value, 2, MidpointRounding.AwayFromZero);

    /// <summary>A part's share of a total, in percent to one place; zero when the total is nothing.</summary>
    private static double Share(double part, double total) => total <= 0 ? 0 : Math.Round(part / total * 100, 1);
}

/// <summary>
/// The last read, served to the cards, each window cached for a minute: the
/// Admin tab is a page somebody leaves open, and the read under it changes
/// once an hour (ADR: What Azure charges).
/// </summary>
public sealed class CostHistoryReader(ICostHistory history, CostReader reader, CostStatus status, TimeProvider clock)
{
    /// <summary>The lock around the cache.</summary>
    private readonly object _gate = new();

    /// <summary>The last answer per window and when it was built, keyed by the window's name.</summary>
    private readonly Dictionary<string, (DateTimeOffset At, CostReport View)> _cached = new(StringComparer.Ordinal);

    /// <summary>
    /// The answer for a window: from the cache when it is under a minute old, built fresh from the
    /// kept days otherwise.
    /// </summary>
    public async Task<CostReport> ReadAsync(string? window, CancellationToken cancellation)
    {
        var now = clock.GetUtcNow();
        string name = CostWindows.Parse(window).Name;
        lock (_gate)
        {
            if (_cached.TryGetValue(name, out var hit) && now - hit.At < TimeSpan.FromMinutes(1))
            {
                return hit.View;
            }
        }

        CostReport view;
        if (!reader.Configured)
        {
            view = CostReport.Absent(name, CostReader.NotConfigured, "USD", null);
        }
        else
        {
            var availability = await history.AvailabilityAsync(cancellation);
            var kept = availability.Available
                ? await history.ReadAsync(now.AddDays(-(CostWindows.ReadDays - 1)).UtcDay, cancellation)
                : CostKept.Empty;
            view = CostView.Build(name, kept, now, availability, status.Snapshot());
        }

        lock (_gate)
        {
            _cached[name] = (now, view);
        }

        return view;
    }
}
```
*Live from [`api/TheYard.Api/CostReport.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/CostReport.cs#L12-L241), region `cost-view`, read from this build at 1d6b50b.*

The tests that hold the view:

```csharp
[Fact]
public void The_donut_names_four_resources_and_folds_the_rest_into_Others()
{
    var days = new[] { 9.0, 5.0, 4.0, 3.0, 2.0, 1.0 }
        .Select((cost, i) => new CostDay("2026-09-29", $"r{i}", "microsoft.web/sites", cost, "USD"))
        .ToList();

    var slices = CostView.Slices(days, new MonthRate(null, 1, 30));

    Assert.Equal(new[] { "r0", "r1", "r2", "r3", "Others" }, slices.Select(slice => slice.Name));
    Assert.Equal(3.0, slices[^1].Cost);
    Assert.Equal(2, slices[^1].Count);
    Assert.Equal(100.0, Math.Round(slices.Sum(slice => slice.Share), 0));
}

[Fact]
public void The_newest_day_is_flagged_partial_and_a_quiet_day_before_it_is_a_zero_not_a_gap()
{
    var now = new DateTimeOffset(2026, 9, 30, 15, 0, 0, TimeSpan.Zero);
    var kept = new CostKept(
        [
            new CostDay("2026-09-27", "aci-theyard-ss", "microsoft.containerinstance/containergroups", 1.0, "USD"),
            new CostDay("2026-09-29", "aci-theyard-ss", "microsoft.containerinstance/containergroups", 0.5, "USD"),
        ],
        // The forecast answer as Azure gives it: the billed days, then the days to come.
        [new CostForecastDay("2026-09-27", 1.0, "USD", Billed: true), new CostForecastDay("2026-09-29", 0.5, "USD", Billed: true), new CostForecastDay("2026-09-30", 0.25, "USD")]);

    var report = CostView.Build("7d", kept, now, new CostHistoryAvailability(true, "kept"), (now, true, null));

    Assert.True(report.Available);
    Assert.Equal("2026-09-29", report.NewestDay);
    Assert.True(report.NewestPartial);
    // The window's first day is 24 September and the newest reported is the 29th: six days, the 28th a zero.
    Assert.Equal(new[] { "2026-09-24", "2026-09-25", "2026-09-26", "2026-09-27", "2026-09-28", "2026-09-29" }, report.Days.Select(day => day.Day));
    Assert.Equal(0, report.Days.Single(day => day.Day == "2026-09-28").Cost);
    Assert.True(report.Days[^1].Partial);
    Assert.False(report.Days[^2].Partial);
    Assert.Equal(1.5, report.Days[^1].Total);
    Assert.Equal(1.5, report.MonthToDate);
    Assert.Equal(1.75, report.ForecastMonth);
    Assert.Equal(new[] { "2026-09-30" }, report.Forecast.Select(day => day.Day));
    Assert.Equal(1.75, report.Forecast[0].Total);
    Assert.Equal("Container instances", Assert.Single(report.Types).Label);
}

[Fact]
public void The_bars_put_the_costliest_type_first_and_a_type_billed_through_another_last()
{
    var days = new[]
    {
        new CostDay("2026-09-30", "app-theyard-ss", "microsoft.web/sites", 0, "USD"),
        new CostDay("2026-09-30", "app-theyard-cosmos-ss", "microsoft.web/sites", 0, "USD"),
        new CostDay("2026-09-30", "app-theshed-ss", "microsoft.web/sites", 0, "USD"),
        new CostDay("2026-09-30", "plan-theyard-ss", "microsoft.web/serverfarms", 0.26, "USD"),
        new CostDay("2026-09-30", "sqldb-theyard-ss-basic", "microsoft.sql/servers/databases", 0.10, "USD"),
    };

    var kinds = CostView.Kinds(days, new MonthRate(null, 1, 30));

    // The apps cost nothing on their own: the plan carries their bill, so they go last.
    Assert.Equal(new[] { "App Service plans", "SQL databases", "App Service apps" }, kinds.Select(kind => kind.Label));
    Assert.Equal(3, kinds[^1].Resources);
}

[Fact]
public void A_window_is_shown_as_a_month_at_the_rate_of_its_finished_days()
{
    // The 24 hour window on 2 October: 1 October finished at $0.74, and 2 October still being added to.
    var now = new DateTimeOffset(2026, 10, 2, 13, 0, 0, TimeSpan.Zero);
    var kept = new CostKept(
        [
            new CostDay("2026-10-01", "plan-theyard-ss", "microsoft.web/serverfarms", 0.43, "USD"),
            new CostDay("2026-10-01", "sqldb-theyard-ss-basic", "microsoft.sql/servers/databases", 0.16, "USD"),
            new CostDay("2026-10-01", "crtheyardss", "microsoft.containerregistry/registries", 0.15, "USD"),
            new CostDay("2026-10-02", "plan-theyard-ss", "microsoft.web/serverfarms", 0.08, "USD"),
            new CostDay("2026-10-02", "app-theyard-ss", "microsoft.web/sites", 0, "USD"),
        ],
        []);

    var report = CostView.Build("24h", kept, now, new CostHistoryAvailability(true, "kept"), (now, true, null));

    // One finished day, scaled to the 31 days of October; the partial day is left out of the rate.
    Assert.Equal(1, report.RateDays);
    Assert.Equal(31, report.MonthDays);
    var plan = report.Resources.Single(slice => slice.Name == "plan-theyard-ss");
    Assert.Equal(0.51, plan.Cost);
    Assert.Equal(13.33, plan.Monthly);
    Assert.Equal(new[] { 13.33, 4.96, 4.65, 0 }, report.Types.Select(kind => kind.Monthly));
    Assert.Equal(22.94, Math.Round(report.Types.Sum(kind => kind.Monthly), 2));
}

[Fact]
public void A_window_with_no_finished_day_is_scaled_from_the_days_it_has()
{
    var points = new[] { new CostPoint("2026-10-02", 0.2, 0.2, true) };

    var rate = MonthRate.For(points, "2026-10-02");

    Assert.Null(rate.PartialDay);
    Assert.Equal(1, rate.BilledDays);
    Assert.Equal(6.2, CostView.Money(rate.Of([new CostDay("2026-10-02", "r", "t", 0.2, "USD")])));
}

[Fact]
public void An_absent_reading_says_why_in_a_sentence_and_draws_nothing()
{
    var now = new DateTimeOffset(2026, 9, 30, 15, 0, 0, TimeSpan.Zero);

    var noStore = CostView.Build("30d", CostKept.Empty, now, new CostHistoryAvailability(false, "no document store"), (null, false, null));
    var noRole = CostView.Build("30d", CostKept.Empty, now, new CostHistoryAvailability(true, "kept"), (now, false, CostReader.NoRoleNote));
    var notYet = CostView.Build("24h", CostKept.Empty, now, new CostHistoryAvailability(true, "kept"), (null, false, null));

    Assert.Equal("no document store", noStore.Note);
    Assert.Equal(CostReader.NoRoleNote, noRole.Note);
    Assert.StartsWith("the first hour's read has not landed yet", notYet.Note);
    foreach (var report in new[] { noStore, noRole, notYet })
    {
        Assert.False(report.Available);
        Assert.Empty(report.Days);
        Assert.Empty(report.Resources);
        Assert.Null(report.ForecastMonth);
    }
}
```
*Live from [`api/TheYard.Tests/CostTests.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Tests/CostTests.cs#L86-L208), region `cost-view-tests`, read from this build at 1d6b50b.*

## Addendum, 2026-09-30: the first read on the Cosmos DB site

Read live at 17:00 CDT, a minute after 1.0.3.55 rolled, uncached on both domains: the SQL site's card drew September at $47.24 with $49.03 forecast (aci-theyard-ss $21.97, aci-theyard-cosmos-ss $13.11, the registry $4.97, the same figures as the portal), and no answer carried the subscription's id. The Cosmos DB site's card said "the last read did not finish (TaskCanceledException); the next is in an hour": its first read, thirty seconds after the start, ran past the twenty seconds the reader allowed while the plan was still loading that site's catalogue, and the recorder then waited the full hour.

So from 1.0.3.56 the reader allows a minute, and a read that did not finish is tried again in five minutes. A read that went through, or one Azure refused, still waits the hour: a missing role does not appear in five minutes, and a request to slow down is a request to slow down.

```csharp
/// <summary>
/// How long to wait after a read. An hour after one that went through,
/// and after one Azure refused: a missing role does not appear in five
/// minutes, and a request to slow down is a request to slow down. Five
/// minutes after one that did not finish, because a timeout says nothing
/// about the next answer, and an hour of an empty card for one slow answer
/// is the wrong trade (ADR: What Azure charges, addendum).
/// </summary>
public static TimeSpan WaitAfter(CostOutcome outcome) => outcome == CostOutcome.Failed ? Retry : Every;
```
*Live from [`api/TheYard.Api/CostRecorder.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/CostRecorder.cs#L31-L39), region `cost-retry`, read from this build at 1d6b50b.*

## Addendum, 2026-09-30: the bars measure dollars

At 19:28 CDT, on his phone, Steve reported that the bar chart on the Cosmos DB site's card was off. The bars measured how many resources of a type were on the bill and were ordered by that count, while each row printed the type's cost on its right. So the longest bar, at the top, was three App Service apps at $0.00, whose bill the plan carries, and a reader saw the biggest bar beside the smallest figure: two measures on one row, and the bar drew the one a bill is not read for.

From 1.0.3.57 a bar is a type's cost, a share of the costliest, ordered by cost; the count is in words beside the name. The apps still show, as an empty bar at $0.00 with "3 resources", which is the true picture: they are on the bill and the plan pays for them. The portal's own card counts resources; this one shows the money and keeps the count, because a reader of this tab came for what it costs.

## Addendum, 2026-10-02: every figure as a month

Reading the card on the last day window, Steve pointed out that the total bill is about $48 a month, and asked for the conversion to happen in the C# code. The resource and type figures were what the window cost, so on the last day window the App Service plan read $0.48, one day of a plan that comes to about $14 a month, on a tab where the bill is read in months.

From 1.0.3.61 the server works out the month and the card only shows it. Each resource and each type carries `monthly`: what the window's finished days cost, divided by how many there are, times the days in the month. The newest day is left out while Azure is still adding to it, because part of a day counted as a whole one would pull the month down; a window with no finished day uses the days it has. The answer carries `rate_days` and `month_days`, and the card says in one sentence what rate its figures come from. The window total and the spend line stay in the window, because the line is a running total of real days.

Measured on 2 October: the one finished day in the last day window, 1 October, cost $0.74, which comes to $22.94 over October's 31 days. The last 30 days cost $45.74, and $32.82 of that was the two container instances, which the bill has not carried since 20 September.

```csharp
/// <summary>
/// How a window's cost becomes a month's: what the finished days cost, divided by
/// how many there are, times the days in the month. The newest day is left out
/// while Azure is still adding to it, because a part of a day counted as a whole
/// one would pull the month down. A window with no finished day uses the days it
/// has, so the card still shows a figure rather than nothing.
/// </summary>
/// <param name="PartialDay">The day left out, as yyyy-MM-dd, or null when every day counts.</param>
/// <param name="BilledDays">How many days the month figure is scaled from.</param>
/// <param name="MonthDays">How many days the month has.</param>
public sealed record MonthRate(string? PartialDay, int BilledDays, int MonthDays)
{
    /// <summary>The rate for a window's days, in the month that holds today.</summary>
    public static MonthRate For(IReadOnlyList<CostPoint> points, string today)
    {
        var day = DateOnly.ParseExact(today, "yyyy-MM-dd", CultureInfo.InvariantCulture);
        int monthDays = DateTime.DaysInMonth(day.Year, day.Month);
        int finished = points.Count(point => !point.Partial);
        string? partial = points.FirstOrDefault(point => point.Partial)?.Day;
        return finished > 0
            ? new MonthRate(partial, finished, monthDays)
            : new MonthRate(null, points.Count, monthDays);
    }

    /// <summary>What these days come to in a month at this rate.</summary>
    public double Of(IEnumerable<CostDay> days) =>
        BilledDays == 0 ? 0 : days.Where(day => day.Day != PartialDay).Sum(day => day.Cost) / BilledDays * MonthDays;
}
```
*Live from [`api/TheYard.Api/CostReport.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/CostReport.cs#L245-L272), region `cost-rate`, read from this build at 1d6b50b.*

## Where it sits

The bill card spans three rings: CostHistory.cs in Application holds the ICostHistory port and, for now, its only implementation, CostsInMemory; the host holds the reader, the recorder and the shaping that strips the subscription id; src/lib/spend.ts lays out the card. Because CostRecorder writes through the port, a kept history would be one adapter and one line in Composition/CostRegistration.cs. It cost a Cost Management Reader role and up to an hour of staleness by design. A year of bills on the card would justify that adapter.

## Files

- [`api/TheYard.Api/Costs.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Costs.cs): the two questions, the shaping, the reader and the recorder.
- [`api/TheYard.Api/CostReport.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/CostReport.cs): the view the cards are drawn from and its cache.
- [`api/TheYard.Application/CostHistory.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Application/CostHistory.cs): the day, the port and the read held in memory, and the windows.
- [`api/TheYard.Api/Composition/CostRegistration.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/CostRegistration.cs): the wiring.
- [`api/TheYard.Api/Endpoints/AdminEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/AdminEndpoints.cs): `GET /api/admin/costs`.
- [`src/components/admin/SpendCard/SpendCard.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/admin/SpendCard/SpendCard.tsx) and [`src/lib/spend.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/spend.ts): the card and the layout it draws from.
- [`api/TheYard.Tests/CostTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/CostTests.cs) and [`src/lib/spend.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/spend.test.ts): the tests above.

## Addendum, 2026-10-17: the bill on the first screen

What Azure charges is on the landing page now, as the band's middle instrument: the month to date as a figure, a bar of it against the month's forecast, and the forecast printed after the fill. It is the same hourly read this record describes, handed out through `/api/pulse` rather than read again (ADR: One reading for the first screen), so the cost reader's rate to Azure is unchanged.

The honest-failure state travels with it. On a container with no identity to ask Azure with, the figure is the word `unread` and the track is empty, never a zero: a zero in that place claims the site is free.
