LatticeStripe.Billing.MeterEventSummary (LatticeStripe v2.0.0)

Copy Markdown View Source

A pre-aggregated total of the usage reported to one billing meter, for one customer, over one window of time.

Wire object billing.meter_event_summary, ids prefixed mtrusg_, served from a single parent-scoped path: GET /v1/billing/meters/:meter_id/event_summaries. That is the only path Stripe serves for this object — there is no top-level /v1/billing/meter_event_summaries collection and no GET /{summary_id} — which is why the meter id is a positional argument to list/4 and stream!/4 rather than a filter, and why this module ships no retrieve function.

Two ways to read a confidently wrong number

The returned object never says which customer it belongs to. It has exactly seven fields, not one of them names a customer, and it cannot be expanded to add one. The customer is an input to the query and never an output. So a reconciler that lists summaries for several customers and merges the results has lost the attribution completely — and the warning sign is easy to miss, because grouping the merged list by a customer field is not something you can even attempt: the field does not exist. Keep the association out of band, alongside the customer id you filtered on.

The figure is not live. Stripe's own specification states that these summaries are eventually consistent. The object carries no freshness field and Stripe publishes no staleness SLA, so a caller has no way to tell how old a number is. Label the figure in your UI with the time you fetched it, never present it as real-time, and never treat it as a billing source of truth — Stripe bills from the meter, not from these summaries.

The window boundary is ambiguous

Stripe's specification contradicts itself about whether the end of the window is included. The end_time query parameter and the end_time field on the returned object are both documented as exclusive, while aggregated_value's own description — on that same object — says the aggregation covers start_time through end_time inclusive. Two of the three say exclusive.

All three descriptions ship verbatim into every SDK's generated documentation, so the same contradiction is waiting in Stripe's other libraries. This library asserts neither reading and does not adjust your window to compensate for either. If one boundary event would change a decision you are making, do not settle it by reading documentation — measure it against your own account.

Listing

customer, start_time and end_time are all required filters. Stripe answers a call missing any of them with an HTTP 400; list/4 and stream!/4 raise ArgumentError before the request leaves the process instead.

Stripe's limit ranges from 1 to 100 and defaults to 10 — and that default is the trap. Ask for hourly buckets across a 31-day month and the window holds 744 of them. A caller who sums the ten rows that come back, without checking whether more exist, gets a plausible-looking number that is about one and a third percent of the truth. (Illustrative, assuming usage spread evenly across the buckets; the real fraction depends on your data.)

The fix is usually not to paginate harder — it is to ask the question you actually have.

For a total, omit value_grouping_window. Stripe aggregates server-side and returns a single bucket, in one request, with no pagination and no client-side float summation. This is what an admin screen showing "usage this period" almost always wants:

params = %{
  "customer" => "cus_123",
  "start_time" => 1_753_660_800,
  "end_time" => 1_753_747_200
}

resp = LatticeStripe.Billing.MeterEventSummary.list!(client, "mtr_123", params)
[summary] = resp.data.data

summary.aggregated_value

For a series, pass value_grouping_window as "hour" or "day" and stream it, so that has_more is followed for you rather than silently ignored:

buckets = Map.put(params, "value_grouping_window", "hour")

client
|> LatticeStripe.Billing.MeterEventSummary.stream!("mtr_123", buckets)
|> Enum.map(&{&1.start_time, &1.aggregated_value})

The difference compounds across a customer base: rendering one usage figure for 200 customers costs roughly 15,000 requests at the default page size, 1,600 at limit=100, and 200 with no window at all. See LatticeStripe.List for the memory guidance that applies to any stream you do not bound with Stream.take/2.

Timestamp alignment

Stripe requires start_time and end_time to be aligned to minute boundaries on every query, to UTC hour boundaries when value_grouping_window is "hour", and to UTC day boundaries (00:00 UTC) when it is "day". The timezone is UTC — not the account's, not the customer's.

The most natural inputs are the ones that violate this. A subscription's current_period_start and current_period_end derive from its billing cycle anchor, so they land on an arbitrary second and are almost never aligned to anything.

list/4 and stream!/4 raise ArgumentError on a misaligned timestamp before the request leaves the process, naming the offending value and the boundary it missed. Stripe would answer with an HTTP 400 whose error code it does not document, so the failure can be prevented but not improved after the fact.

This library will not align them for you. Rounding changes what the query means: floor the start and you sweep in usage from before the period; ceil it and you drop usage that belongs to it. That is a business decision, not a formatting detail, and a library that makes it silently produces a wrong number its caller never sees. Do the arithmetic yourself, where you can see it:

start_time = Integer.floor_div(start_time, 86_400) * 86_400
end_time = -Integer.floor_div(-end_time, 86_400) * 86_400

Integer.floor_div/2 rather than div/2div/2 truncates toward zero, which rounds the wrong way for negative inputs.

Design

Three things this module deliberately does not have:

  • No retrieve function. Stripe serves no get-by-summary-id route, so there is no operation to wrap. This is an absence in the API, not a gap to apologise for.
  • No window-aligning helper. Rejected on domain grounds: any snap has to choose floor or ceil, that choice changes which usage the window covers, and adjacent metering platforms that do auto-align demonstrate the cost — one of them turns a three-day range into four windows without saying so.
  • No convenience accessor on LatticeStripe.Billing.Meter. Parent-scoping is expressed in this module's signatures, not by a delegator on the parent. Stripe's own Java SDK documentation once advertised exactly such a method; it never existed, and Stripe's answer was that the documentation was wrong.

Note the read/write type asymmetry: aggregated_value comes back as a float, while LatticeStripe.Billing.MeterEvent writes take the value as a decimal string. See guides/metering.md for the payload contract.

See the Stripe Meter Event Summary API.

Summary

Functions

Decode a Stripe-shaped string-keyed map into a %MeterEventSummary{}.

List one page of usage summaries for a meter.

Returns a lazy stream of every usage summary in the window (auto-pagination).

Types

t()

@type t() :: %LatticeStripe.Billing.MeterEventSummary{
  aggregated_value: float() | nil,
  end_time: integer() | nil,
  extra: map(),
  id: String.t() | nil,
  livemode: boolean() | nil,
  meter: String.t() | nil,
  object: String.t() | nil,
  start_time: integer() | nil
}

Functions

from_map(summary)

@spec from_map(map() | t() | nil) :: t() | nil

Decode a Stripe-shaped string-keyed map into a %MeterEventSummary{}.

aggregated_value arrives as a JSON number and stays a float — it is never rounded and never coerced to an integer. Note the asymmetry with the write path: reads return a float, while LatticeStripe.Billing.MeterEvent writes take the value as a decimal string.

Idempotent: applied to an already-decoded struct it returns it unchanged, and from_map(nil) returns nil. Unknown top-level keys land in :extra.

list(client, meter_id, params \\ %{}, opts \\ [])

List one page of usage summaries for a meter.

Sends GET /v1/billing/meters/:meter_id/event_summaries.

params must carry "customer", "start_time" and "end_time" — Stripe marks all three required. Each guard raises ArgumentError before any network call, in that order, and so does an empty or nil meter_id.

Those guards check key presence, not value emptiness, and read string keys only (Stripe wire format): a "customer" key whose value is "" passes the guard and fails at Stripe instead, and an atom-keyed params map bypasses the guards entirely.

Timestamps are Unix seconds. Stripe requires them aligned to minute boundaries always, to UTC hour boundaries when value_grouping_window is "hour", and to UTC day boundaries (00:00 UTC) when it is "day". A misaligned value raises ArgumentError here too, printing the arithmetic rather than applying it — see the module docs for why the choice of floor versus ceil is yours.

Also supports Stripe's limit (default 10, max 100) and starting_after / ending_before cursors. A 31-day hourly window is 744 buckets, so a bare list/4 over one silently returns the first ten.

list!(client, meter_id, params \\ %{}, opts \\ [])

Like list/4 but raises on failure.

stream!(client, meter_id, params \\ %{}, opts \\ [])

@spec stream!(LatticeStripe.Client.t(), String.t(), map(), keyword()) ::
  Enumerable.t()

Returns a lazy stream of every usage summary in the window (auto-pagination).

Emits individual %MeterEventSummary{} structs, following has_more and fetching each subsequent page as the stream is consumed. Raises LatticeStripe.Error if any page fetch fails, so a partial enumeration surfaces as an error rather than as a short — and silently wrong — series.

This is the correct entry point for a bucketed series. Stripe's limit defaults to 10 while a 31-day hourly window is 744 buckets, so a bare list/4 over one returns the first ten and no indication that a sum over them is a fraction of the truth. For a single total, omit value_grouping_window and use list/4 — one server-aggregated bucket, one request, no pagination.

The same guards list/4 applies fire here, and they raise at call time rather than at the first Enum step, so the failure lands at the call site.

Consume it with Enum.to_list/1 when you intend to hold every bucket in memory, or bound it with Stream.take/2 when you do not — see LatticeStripe.List for the memory guidance:

client
|> LatticeStripe.Billing.MeterEventSummary.stream!(meter_id, params)
|> Enum.map(& &1.aggregated_value)

There is no non-bang stream/4 twin — a lazy stream cannot return an error tuple at construction time for a failure that happens pages later.