Aurora Meter Pro changelog

Latest release v1.0.0 · 2026-09-22 · this is the file that ships inside the package

Changelog

All notable changes to Aurora Meter Pro are documented here. Format based on Keep a Changelog; this project adheres to Semantic Versioning.

[1.0.0] - 2026-09-22

The first stable release. The archive audited for release, the clean room installation and the soak all ran against this exact tree, not against an approximation of it.

Pro 1.0 requires core 1.0, and that is the whole upgrade order

The compatible core range is >= 1.0.0-rc.1 and < 2.0.0. Upgrade aurora_meter first. A host that moves Pro ahead of core now fails to resolve, which is the point: the previous requirement was ~> 0.4 or ~> 0.5, ~> 0.4 admits everything below 1.0.0, and Hex would pick the newest published core, 0.4.0, against which this package does not compile at all. That was true of the published 0.3.0 as well and no build in the workspace could see it, because a sibling checkout on disk always won (open-findings.md X420).

Upgrading

AuroraMeter.Pro.Migration.latest_version() is 11; 0.3.0 shipped 9.

  1. Run core’s migrations first, in full, to core schema 10. Pro’s version 10 reads tables core’s version 9 creates.

  2. Generate and apply Pro’s:

    mix aurora_meter_pro.gen.migration --upgrade -r MyApp.Repo
  3. Stop every 0.3.0 reporter before Pro version 10 runs. Version 10 turns each staged usage report into an outbox item; a 0.3.0 reporter still writing usage_reports.pending_* while that runs stages work the outbox has already taken.

The aurora_meter_usage_reports.pending_entry, pending_usage and pending_since columns are deprecated and not dropped. Version 10 copies every in-flight pending_entry into an outbox item and deliberately leaves the column populated, so a rollback to 0.3.0 still has something to resend from. Nothing in this release writes them. They are removed in 2.0.

Two behaviour changes worth reading before you deploy:

  • AuroraMeter.Pro.Live.Dashboard no longer accepts params["tenant"]. It reads session["tenant"] and ignores params entirely, and a session with no "tenant" key raises at mount rather than rendering someone else’s figures. A tenant taken from the query string is an authorization decision made by the URL.
  • AuroraMeter.Pro.UsageReporter.report_all/0 stages rather than sends. The outbox delivers, with retry, idempotency and a quarantine. Anything that called report_all/0 and then assumed the provider had been told needs to wait on the outbox instead.

Erlang/OTP 26 is the floor and Hex cannot express it. A package’s metadata has an elixir requirement and no OTP field, so a host on OTP 25 will resolve this package and fail while compiling the Stripe client’s HTTP stack, whose quic dependency declares {minimum_otp_vsn, "26"}. The floor is stated in the package description, in README.md and here, and enforced by CI.

docs/api.md marks an entry that is here but not yet on Hex with Since 0.4.0, and this section is what those entries rest on: a public function with no changelog line is how a consumer is surprised by an upgrade (open-findings.md X86). Those markers are stale as of this release and are 02a’s to sweep; the entries themselves are below.

The webhook_secret rotation fix is deliberately not here. It ships in 0.3.1, a patch cut from the v0.3.0 tag, because a host in the middle of a rotation should not have to take the clock adoption below to get it. See the 0.3.1 section once that release is published.

Changed

  • The dashboard’s Spendable figure reads $0.00 on a wallet that owes money. Nothing in Pro changed: AuroraMeter.Pro.Components.money_section/1 renders what AuroraMeter.Credits.summary/1 reports, and core now reports what its ledger will actually accept rather than what the lots hold. A wallet can show a positive Balance, a positive Owed and Spendable at $0.00 at the same time, which is the true reading: while a debt stands, every hold/4 and debit/4 behind that page is refused, with the new {:error, :debt_outstanding}. See core’s CHANGELOG.md and the “Debt” section of its docs/credits.md.

Added

  • An optional LiveDashboard page for commercial operations. AuroraMeter.Pro.LiveDashboard.Page (init/1, menu_link/2, mount/3, handle_refresh/1, render/1) shows the export backlog per state, the oldest uncertain items with the exact AuroraMeter.Pro.Recovery.inspect_item/1 call to paste, quarantined items grouped by reason, current leases, open reconciliation differences, pending payments with the age and whether it is past uncertainty_horizon_seconds, the recovery audit trail and the provider mapping. Compiled only when the new optional phoenix_live_dashboard dependency is installed.

    :authorized_by is required and has no default, and this page refuses authorized_by: :host_route, which the core page accepts: every section here renders tenant keys, subject references and payment state, and “the route is authenticated” is not a basis for showing one customer’s payment state to whoever reached the dashboard.

    It is read-only. It renders the recovery audit trail and the call to paste, and ships no button that performs one, because those mutations require an expected: map and an evidence: string that a one-click button cannot supply honestly. A section it could not read renders “unavailable” with an error class, never a 0 and never an empty table: a backlog that renders zero because its query failed reads as “nothing is stuck”, and the only reason anybody opens this page is that something is.

  • A span around every outbound provider call. [:aurora_meter, :pro, :provider], whose three names are [:aurora_meter, :pro, :provider, :start | :stop | :exception], emitted by AuroraMeter.Pro.Telemetry.provider_span/2 at the seam where Pro calls the configured client, so one span is one HTTP attempt and never the loop that retries it. operation is one of AuroraMeter.Pro.Telemetry.provider_operations/0, a closed set of callback names. No request body, no response body, no customer identifier and no payment identifier is on the span. Pro emitted no provider telemetry at all before this, so the slowest thing in the export path was invisible.

  • docs/alerts.md: six worked alert examples with the derivation of each threshold from a value you control, and a “do not alert on this” section. They are deployment examples, not service level objectives.

  • A telemetry catalogue, presets, and a page that cannot drift from the code. AuroraMeter.Pro.Telemetry.events/0 and event_names/0 return every Pro event as data, with its real measurement and metadata keys and the tags a metric may use; AuroraMeter.Pro.Telemetry.Metrics.metrics/1 and groups/0 are Telemetry.Metrics presets over them, compiled only when the new optional telemetry_metrics dependency is installed. A host wanting both halves writes AuroraMeter.Telemetry.Metrics.metrics() ++ AuroraMeter.Pro.Telemetry.Metrics.metrics(). New docs/telemetry.md: this package had no telemetry page at all.

  • AuroraMeter.Pro.Outbox.gauge/0: emits one [:aurora_meter, :pro, :outbox, :gauge] sample on demand, for a host whose deliverer is paused or that runs the queue on another scheduler. The deliverer already emitted one at the end of every run and now calls this, so there is one implementation of the aggregate rather than two. When the aggregate query fails it raises and emits nothing, because a zero on this gauge reads as an empty backlog and the case it exists for is the one where the backlog is not empty and nobody can see it.

  • [:aurora_meter, :pro, :retention, :prune] is documented. It has been emitted on every retention run since 0.4.0. It was emitted through a module attribute and the guard that checks docs/api.md against lib/ matched only a literal event name, so the event could be neither found nor documented without failing that guard. The guard now reads the parsed source.

  • The reason metadata key on [:aurora_meter, :pro, :outbox, :deliver] is documented. It was always emitted on one of that event’s two sites.

Added

  • Version-keyed Stripe price mapping, and plan changes the provider makes first. :stripe_prices and :stripe_metered_prices are keyed by {plan_id, plan_version}; a bare plan id still means version "1", so every 0.x mapping keeps working. AuroraMeter.Pro.Subscriptions.plan_ref/1 answers the pair, sync/1 writes both columns, and boot refuses a mapping in which one price id is claimed by two versions, because resolving a subscription’s plan from its item prices cannot be deterministic while two claim the same price and a customer would be attributed to whichever the map enumerated first. A new commercial version gets a new price object with a new lookup key; existing prices are never re-pointed (decision D02).
  • AuroraMeter.Pro.Transitions.schedule/3 and status/2, and the AuroraMeter.Pro.Transitions.Applier Oban worker: a plan change is sent to Stripe with a stable idempotency key and recorded locally only once Stripe’s own retrieved state shows it. A crash between the two converges, because the applier’s first act is to observe the provider rather than to re-decide. A refusal leaves the transition pending and opens a plan_change_rejected reconciliation item; it is never applied locally, because that would entitle a customer Stripe is not billing.
  • AuroraMeter.Pro.Stripe.describe_plan_change/3 (no network, for AuroraMeter.Subscriptions.preview_transition/3) and update_subscription_plan/3 (the network call).
  • Configuration :proration_behavior (default :none) and :stripe_subscription_client (default Stripe.Subscription).
  • Telemetry [:aurora_meter, :pro, :transition] and [:aurora_meter, :pro, :usage_report, :plan_fallback].
  • Two AuroraMeter.Pro.Reconcile item kinds: unmapped_price, opened when a synced subscription’s prices map to no plan at all, which used to be a silent fall back to the default plan; and plan_change_rejected.
  • Pro schema version 11: plan_id and plan_version on aurora_meter_usage_reports, stamped when the row is created and never updated, so a report for a closed period reports against the plan version that period was sold under. Plus pending_outcome on aurora_meter_credit_accounts, reserved by schema step S8 and written by nothing in this release. Additive, transactional, no backfill.

Fixed

  • One node death stopped the export path permanently, and nothing said so. AuroraMeter.Pro.Outbox.Deliverer declared unique: [period: :infinity, states: <every incomplete state>], and that list contains :executing. A node killed mid-delivery leaves its job executing and nothing ever moves it, because nothing observed the death; an infinite period never lapses, so every later enqueue was deduplicated against that corpse. No deliverer was ever created again.

    Measured in a V1 soak run: a node killed one second after it began a deliverer job, the last provider call at 09:24:16 and none in the remaining three minutes, 225 outbox items left leased by a node that no longer existed with leases nothing reclaimed, and pending climbing 350 to 2,762 monotonically. Oban itself was healthy and eleven other jobs ran, so nothing looked broken. Nothing in the ledger was wrong either. What had stopped was the path that turns correct usage into revenue: usage accumulating that would never be billed, with no error, no retry and no alert (open-findings.md X486).

    AuroraMeter.Pro.Outbox.Deliverer and AuroraMeter.Pro.UsageReporter are now unique for 900 seconds and AuroraMeter.Pro.Outbox.Reconciler for 1800: twice each worker’s documented schedule, rounded up to the next quarter hour. A wedged worker now resumes at the first tick more than that period after the wedge, which is 20 minutes for the deliverer and the reporter on */5 and 40 for the reconciler on */10, rather than never. The other Pro workers were already bounded and are unchanged.

    Bounding the period costs a duplicate run whenever a healthy run exceeds it, and a duplicate deliverer has always been safe: the lease is the lock, twelve connections claiming one item produce exactly one claim, and four workers over fifty items produce fifty accepted items and no duplicate effect. The outbox’s at-least-once contract is unchanged, and so is every state transition: this release changes three numbers and the pages that describe them.

  • The scheduler map names Oban.Plugins.Lifeline, which neither package had ever mentioned. A finite uniqueness period keeps a worker alive; it does not clear the orphaned job, and nothing in this package can. The recommended Oban configuration now carries the plugin, with the one thing an operator has to get right stated next to it: rescue_after must be longer than the slowest legitimate run, because Lifeline rescues on elapsed time alone and cannot tell a dead node from a slow one. AuroraMeter.Oban.validate!/1, from the free core, warns when it is absent. A new section, a node that dies mid job, states what one costs with the plugin, without it, and before this release, all three measured with a real SIGKILL.

  • mix aurora_meter_pro.gen.migration works. In every host before this it raised before it did anything. The task read the -r argument with Mix.Ecto.parse_repo/1, which turns it into an atom, and never called Mix.Ecto.ensure_repo/2, which is what loads the host’s application. So the module that atom names was not on the code path, and the task’s first act, Ecto.Migrator.migrations_path/1, called repo.config/0 on it:

    ** (UndefinedFunctionError) function MyApp.Repo.config/0 is undefined
       (module MyApp.Repo is not available)

    This was the only documented way to install the Pro schema, so no host could install this package by following its own getting-started page. The free package’s generator has always had the call.

    It was invisible from inside the suite for a reason worth stating, because it is the second time it has cost this project a release blocker. Both packages’ generator tests hand the task a repo module that this package’s own test VM had loaded before any test ran, and every test asserted what the task wrote. A generator test that never runs the generator in the state a host is in is testing a file writer. Mix.Tasks.AuroraMeterPro.Gen.MigrationTest now has a describe "the migration the generator produces" block that gives the task a repo this VM has not loaded, asserts the refusal is a named Mix.Error and never an UndefinedFunctionError, asserts the free package’s generator answers a repo it cannot resolve identically, and compiles the file the task wrote rather than matching strings in it.

  • AuroraMeter.Pro.UsageReporter no longer stages a buffered window for a feature whose reporting source is :events. The reporter decided from the cutover watermark alone. A feature declared config :aurora_meter, feature_sources: %{tokens: :events} and never reported from the counter has no watermark, and at nil the check answered “this period is not after a cutover” and staged the window. So the crontab this package recommends and a configuration this package supports could not run together: one aurora_meter_usage_reports row later, the next node to start raised AuroraMeter.Pro.CutoverRequiredError naming a cutover the installation had never performed, and a host that followed docs/operations/scheduler.md bricked its application.

    The check is right and the row should never have existed. The reporter now reads the feature’s source when there is no watermark, which makes it and AuroraMeter.Pro.Outbox‘s check exact complements everywhere rather than only where a watermark exists. This is what docs/usage-reporting.md has said since the source key was introduced and what the architecture map records as invariant I08; the code was the only one of the three that disagreed.

    Nothing about a cutover drain changes. During one, feature_sources still says :buffered and the watermark still decides, which is the same branch as before. The recommended crontab is unchanged and still includes the reporter: a host with one events-source feature and three buffered ones needs it for the three, and dropping the worker would have stopped reporting them silently.

  • mix aurora_meter_pro.install now exits 1 when it refuses, and --dry-run prints its change set in a script. The task refuses to schedule the expiry sweep twice, which is correct, and it printed that refusal, wrote nothing, and exited 0: no script, CI step or install recipe could tell a refusal from a success. Igniter also treats a run whose standard input is not a character device as --yes, and --yes suppresses the diff, so --dry-run wrote no file (right) and reported no change set (its entire purpose). Both edges now live in one shared place in the free package, because Aurora Meter’s own installer has exactly the same two.

  • A completed checkout session no longer activates a subscription whose payment has not settled (open-findings.md P9). sync_from_session/1 hard-coded status: "active" for every completed session; it now derives the status from payment_status, and a session that is complete but unpaid produces "incomplete", which is not an entitled status. The customer.subscription.* webhook, which carries the real status from a fresh retrieve, corrects it seconds later. A host that relied on instant entitlement after a redirect will see a short window where the customer is not entitled. That is the correct behaviour: a browser arriving back from Checkout is not the provider saying the payment settled.

  • A subscription object with no status is refused rather than defaulted into an entitlement (the other half of P9). sync/1 answered to_string(object["status"] || "active"), so a truncated body entitled the customer; it now returns {:error, {:invalid, [status: "missing"]}}, the webhook answers 500, and Stripe redelivers.

  • A tenant on version 1 keeps version 1’s caps and metered features when version 2 ships. AuroraMeter.Pro.Alerts and AuroraMeter.Pro.UsageReporter resolved the plan with AuroraMeter.Plans.get/1, which answers the version effective now, so both began reading version 2’s declarations the moment version 2’s effective_at passed. Both now resolve the tenant’s own version (G07 bullet 1).

  • AuroraMeter.Pro.Alerts no longer queries AuroraMeter.Schema.Subscription directly: it walks AuroraMeter.Storage.list_subscriptions/2 in pages, which also stops check_all/0 loading every subscription in the installation into one list. Pro’s lib/ now builds no query against that table at all.

Changed

  • BREAKING: the optional phoenix_live_view requirement is now ~> 1.0. It was ~> 0.20 or ~> 1.0, and core changed in the same release, so there is no pairing in which one of the two packages still claims 0.20.

    This is breaking at resolution. If your application has phoenix_live_view resolving to 0.19 or 0.20, your next mix deps.get will refuse where it previously succeeded.

    # your mix.exs, before: this resolved
    {:phoenix_live_view, "~> 0.20"},
    {:aurora_meter_pro, "~> 0.3", organization: "phxtemplates"}
    
    # after: this does not
    {:phoenix_live_view, "~> 0.20"},
    {:aurora_meter_pro, "~> 1.0", organization: "phxtemplates"}

    What to do: upgrade LiveView to 1.0, or drop the optional dependency. A Pro host with no LiveView keeps the Stripe provider, the export outbox, reconciliation, recovery, credits, alerts and rollups, all of which are data and behaviour with no view in them, and loses AuroraMeter.Pro.Components and AuroraMeter.Pro.LiveDashboard.Page.

    Why. Every template in this package uses LiveView 1.0’s curly body interpolation, which 0.20 renders as literal text: 133 occurrences in Pro alone, in pro/components.ex and pro/live_dashboard/view.ex, counted rather than estimated. A 0.20 host compiled cleanly and rendered {@title} at its own operators. The requirement promised something the package had never done.

    If you do not have phoenix_live_view in your dependencies at all, nothing here affects you. Core’s mix aurora_meter.install --check-support answers for a Pro host too, and now exits non-zero when a dependency is present without its integration compiled, which is the state a host reaches by adding LiveView after aurora_meter was built.

  • BREAKING, and it is a security fix: AuroraMeter.Pro.Live.Dashboard takes the tenant from the session and from nowhere else.

    Before this release mount/3 read params["tenant"] || session["tenant"], and the moduledoc advertised the route live "/usage/:tenant". Params took precedence over the session, so any signed-in customer who edited the URL read another customer’s usage, plan, credit balance and spend, and subscribed to that customer’s live usage feed. It is an insecure direct object reference in a paid product and there was no test over it (open-findings.md P8).

    What changed:

    • the tenant comes from session["tenant"] only, and params are ignored entirely;
    • a session with no "tenant" key raises ArgumentError at mount, naming the key and the reason. A missing tenant must be loud: rendering an empty dashboard would hide the wiring error and rendering a default tenant would show somebody else’s numbers;
    • session["aurora_meter_dashboard"] is an optional keyword list passed to AuroraMeter.Pro.Dashboard.load/2 (for example [days: 7]), so a host needs no params for presentation either.

    Upgrade steps. If you mounted the advertised param route, move the tenant into the session:

    # router.ex, before
    live "/usage/:tenant", AuroraMeter.Pro.Live.Dashboard
    
    # router.ex, after: inside an already-authenticated scope
    live_session :customer, on_mount: {MyAppWeb.Auth, :put_aurora_tenant} do
      live "/usage", AuroraMeter.Pro.Live.Dashboard
    end

    and put the tenant in the session from the hook, after you have authenticated the current user and checked that this user may see that tenant. This LiveView cannot make that check for you and no longer pretends to.

    A host that upgrades without doing this gets an ArgumentError at mount, which is a 500 on that page. That is the intended failure: it is loud, it is immediate, and the alternative is the cross-tenant read this release removes.

  • AuroraMeter.Pro.Config.plan_for_price/1 returns {plan_id, version} rather than a bare plan_id atom, and nil is unchanged. A price id identifies a commercial contract, and a contract is a plan and a version: answering with the id alone made a Stripe-confirmed upgrade to exactly the version a tenant had scheduled indistinguishable from the version they were already on, so core’s precedence rules cancelled the transition instead of applying it (open-findings.md X296). A host that pattern matched on a bare atom breaks loudly rather than silently.

  • AuroraMeter.Pro.Subscriptions.plan_id/1 is deprecated (removed in 2.0). It is elem(plan_ref/1, 0) and drops half of the contract.

  • AuroraMeter.Pro.Stripe.checkout_params/2 takes :version and puts plan_version in the session and subscription metadata, so a brand-new subscription whose price is not yet mapped is still attributable.

  • Payments carry their provenance onto the credit lot, and refunds follow it back. A successful payment’s grant now stamps source: %{payment_intent_id: ..., checkout_session_id: ...} on the paid lot it creates, and every refund, dispute and reconciliation writes through AuroraMeter.Credits.reverse_lot/4 and AuroraMeter.Credits.restore_lot/4, which touch only the lots that payment funded. On a wallet that has been cut over to lots a refund can no longer consume promotional credit granted after the payment. The grant reference is unchanged (the bare PaymentIntent id), so every historical row and every idempotency guarantee is untouched.

  • Two AuroraMeter.Pro.Reconcile item kinds: reversal_without_grant, for a refund or dispute that cannot be placed and would otherwise have been answered 200 with no record, and card_not_saved, for a payment whose credit is committed and whose card could not be saved. No migration: the kind column is free text with an application-level list.

  • Telemetry [:aurora_meter, :pro, :credits, :wallet_wide_fallback] (measurement count, metadata tenant_key, payment_intent_id, operation), emitted when a refund or restoration falls back to the wallet-wide path because the payment has no lots. A fleet that has finished the wallet migration should see it stop.

  • AuroraMeter.Pro.Retention, and AuroraMeter.Pro.Retention.Pruner. A closed, compile-time allow list of the five operational tables Aurora Meter Pro may delete from, each with an age predicate and a state predicate: aurora_meter_audit_events, confirmed aurora_meter_outbox_items, aurora_meter_webhook_events, aurora_meter_recovery_actions and resolved aurora_meter_reconciliation_items. plan/1 is a dry run that runs the prune’s own predicate with count(*) and writes nothing; prune/1 deletes in bounded batches with a per-run budget, and reports a paused or budget-exhausted table rather than failing. tables/0, protected/0 and operation/1 expose the lists and the pause names, and AuroraMeter.Pro.Retention.Pruner‘s operations/0 names every checkpoint the worker pauses under. Nothing financial is on the allow list at any age: aurora_meter_usage_reports, aurora_meter_credit_accounts, aurora_meter_rollups, aurora_meter_alerts and aurora_meter_source_cutovers are never deleted, and neither is an outbox item that is not confirmed or a reconciliation item that is still open. A test derives the tables the migrations create and fails unless every one is classified, in both directions. See Retention.

  • AuroraMeter.Pro.AuditLog.plan/1, the dry-run counterpart of prune/1. Both go through AuroraMeter.Pro.Retention now, so the count is the prune’s own predicate rather than a second one that could drift, and prune/1 gained a per-run budget (:max_items, default 100,000) so a first sweep of a long-unpruned table can no longer run for an unbounded time.

  • Configuration: :webhook_event_retention_days (30, accessor webhook_event_retention_days/0), :recovery_action_retention_days (365, accessor recovery_action_retention_days/0) and :reconciliation_item_retention_days (365, accessor reconciliation_item_retention_days/0), joining the existing audit_log_retention_days (30) and outbox_retention_days (90). Every one is a number of days with a floor of one day, refused at boot below it: a retention window is compared against a stored timestamp, and the clocks that write those timestamps step backwards by up to a few seconds, so a window of minutes is not a sound test and this decision cannot be undone.

  • The export outbox, and Pro schema version 10. Usage no longer goes to Stripe from inside the reporter. AuroraMeter.Pro.UsageReporter computes what is owed and stages one immutable row in aurora_meter_outbox_items, and AuroraMeter.Pro.Outbox.Deliverer (a new Oban worker, which a host must add to its crontab) sends it. Each row carries its own identity, attempt count, next attempt time and fencing token, so delivery is retried per item rather than per run and one stuck customer no longer holds up everyone else’s. Version 10 also adds the webhook dedupe, source cutover, reconciliation and recovery-action tables that the rest of phase 04 writes into.

  • AuroraMeter.Pro.Provider.Outcome: one reading of what Stripe just said, shared by the outbox and the auto top-up worker. HTTP 429 is a retry, never a rejection, and an answer nobody understood is uncertain rather than accepted or rejected.

  • AuroraMeter.Pro.Exporter.StripeMeterEvents, an AuroraMeter.Exporter. It sends what it is given, from the item’s frozen payload only, keyed on the payload’s identifier, and says what happened to each item. Its describe/0 carries Stripe’s documented limits, read on 2026-09-15 and recorded with their URLs in docs/evidence/v1/phase-04/stripe-meter-contract.md.

  • AuroraMeter.Pro.Outbox, an AuroraMeter.Events.Outbox, so a durable event staged through AuroraMeter.record/4 gets its export intent inside the same transaction as the fact. An event this package cannot deliver is staged quarantined with the reason, never dropped.

  • AuroraMeter.Pro.Lock.with_lock/2, the advisory lock moved out of AuroraMeter.Pro.Credits with one fix: a failed unlock is now logged instead of raised, so it cannot replace the exception the locked body raised.

  • Twelve configuration keys for the outbox (:stripe_account_id, :stripe_mode, :mapping_version, :exporter, :outbox_lease_seconds, :outbox_max_attempts, :outbox_batch_size, :outbox_backoff_base_seconds, :outbox_backoff_cap_seconds, :outbox_rate_limit_delay_seconds, :uncertainty_horizon_seconds, :reporter_batch_size), plus :stripe_meter_client, which is internal and exists so a test can substitute a Stripe double that is visible across processes. Three telemetry events: [:aurora_meter, :pro, :outbox, :deliver] per item, [:aurora_meter, :pro, :outbox, :gauge] once per run with a count for every state, and [:aurora_meter, :pro, :outbox, :stale_worker] when a conditional update affects zero rows. All are listed in docs/api.md. [:aurora_meter, :usage_report] is unchanged and still fires when a usage window is acknowledged, from the deliverer rather than from the reporter: renaming a published event silently detaches every handler attached to it.

  • AuroraMeter.Pro.Period.containing/2: which period held a past instant. It answers the subscription window when the instant falls inside it, and the calendar month with source: :calendar_fallback when it does not, so a caller can tell an answer from a guess. It implements the optional containing/2 callback that core 0.5.0 adds to the AuroraMeter.Period behaviour.

  • AuroraMeter.Pro is now something you put in your supervision tree, and it starts nothing. AuroraMeter.Pro.child_spec/1 and AuroraMeter.Pro.start_link/1 run three boot checks and then return :ignore, so a supervisor containing it shows no process under it. Put it after your Repo. AuroraMeter.Pro.validate!/0 is the first check on its own, for a release health check that wants to fail a deploy without booting the app, and AuroraMeter.Pro.account_confirmed?/0 reports whether the third one succeeded.

  • The :aurora_meter_pro configuration is validated, once, against a schema. Every key Pro reads is declared with a type and a default, a misspelled key is reported rather than ignored (a warning in this release, a refusal in 1.0), and the accessors read the validated result. Five checks that a type cannot express run with it: :stripe_account_id is required once anything is mapped for export, :stripe_mode must agree with the API key’s prefix, a meter name must not be empty, :api_version must look like a Stripe version, and the {:system, "VAR"} behind a required key must actually be set. AuroraMeter.Pro.ConfigError is what any of them raises, and it prints an API key’s prefix and never the key.

  • {:system, "VAR"} is resolved at validation instead of on every read. A variable changed after boot no longer changes what a running node does. This is a behaviour change and it is deliberate: per-call resolution made the configuration unauditable and made the account and mode checks meaningless, because the value they checked was not the value the next call would use.

  • The source cutover. AuroraMeter.Pro.Cutover.schedule/3 records the period start from which a feature’s quantity comes from durable events instead of the buffered counter, and watermark/1, status/1 and list/0 read it. The reporter stages a buffered window only while period_start < w and AuroraMeter.Pro.Outbox accepts an event only while period_start >= w, so a period belongs to exactly one path and never to both. usage_reports.source records which one it was, written once and never rewritten. mix aurora_meter_pro.cutover schedules and inspects it, and refuses to guess a watermark. The whole procedure is in the cutover guide.

  • Booting with a feature set to :events that has already been reported from the buffered counter, with no cutover, now fails. AuroraMeter.Pro.CutoverRequiredError names the feature, how many aurora_meter_usage_reports rows it has and the exact Mix command. All three conditions have to hold, so an installation that has not used a feature commercially boots unchanged.

  • The webhook records every event it receives. aurora_meter_webhook_events gets a row before the handler runs and is marked applied after, so a redelivery of an event that was applied answers 200 immediately, takes no lock and makes no Stripe call. A redelivery whose first delivery crashed runs the handler again: every handler is idempotent and refusing would strand it. The table makes a redelivery cheap; it is not what makes the handlers safe.

  • The webhook checks an event’s mode and account. An event whose livemode disagrees with :stripe_mode, an event carrying no livemode, an event from a Connect account that is not :stripe_account_id, and a payment event for a tenant whose credit account records a different account all answer 500, log loudly and apply nothing. Not 200: quietly discarding an event from the wrong mode or account hides a misconfiguration that is, by construction, able to move money in the wrong place. A credit account is stamped with the account and mode on first use; a NULL means “not yet stamped”, never “mismatch”, because the database never recorded which account a pre-version-10 payment belonged to.

  • The Stripe API version is pinned on both sides, and a disagreement is visible. :api_version (default "2025-11-17.clover", the version this release was tested against) is the Stripe-Version Pro sends, written into config :stripity_stripe, :api_version at validation so an SDK upgrade cannot change the wire contract silently. :webhook_api_version is the version received events are expected to carry, which Pro cannot set: Stripe serialises an event at the webhook endpoint’s version, or the account’s default when the endpoint names none. A disagreement is recorded on the event row, carried in telemetry and logged once per distinct version. It is never refused, because the event is a real fact about real money.

  • AuroraMeter.Pro.Config.mapping_fingerprint/0, a sha256 over the three mapping maps. :mapping_version is the host’s promise that a mapping changed; this is the fact, so a mapping change made without bumping the version is detectable afterwards by comparing two recorded fingerprints.

  • Five configuration keys: :stripe_account_check (:skip | :best_effort | :require, default :best_effort, so a Stripe outage warns instead of blocking a deploy), :api_version, :webhook_api_version, :outbox_retention_days (read by a later release’s retention worker) and the internal :stripe_account_client. Accessors for every key added in this release, which were missing for the twelve outbox ones: stripe_account_id/0, stripe_mode/0, stripe_account_check/0, api_version/0, webhook_api_version/0, expected_webhook_api_version/0, mapping_version/0, mapping_fingerprint/0, exporter/0, outbox_lease_seconds/0, outbox_max_attempts/0, outbox_batch_size/0, outbox_backoff_base_seconds/0, outbox_backoff_cap_seconds/0, outbox_rate_limit_delay_seconds/0, outbox_retention_days/0, uncertainty_horizon_seconds/0 and reporter_batch_size/0, plus validated/0 and invalidate!/0 for a health check that has just rewritten the environment.

  • Two telemetry events: [:aurora_meter, :pro, :webhook] per received event (duration_ms; type, outcome, duplicate, api_version) and [:aurora_meter, :pro, :cutover] when a watermark is scheduled.

  • Two outbox quarantine reasons: :mode_mismatch, when :stripe_mode disagrees with the API key actually configured, and :account_unconfirmed, when a live-mode installation never confirmed its account against Stripe.

  • Corrections reach Stripe. AuroraMeter.correct/4 staged a correction item that had nowhere to go; AuroraMeter.Pro.Exporter.StripeMeterEvents now delivers one, and describe/0 lists :correction in the same change that made it true. Stripe’s adjustment API cancels a whole meter event by its identifier and has no partial adjustment, so a correction is two requests in a fixed order: cancel what is standing, then re-send the net under a derived identifier. Both idempotency keys come from the item’s own immutable identifier, so a retry repeats both and Stripe deduplicates each. A crash between them under-reports until the retry lands, which is the safe direction; the reverse order would over-report. No negative meter event is ever fabricated, and a net of zero sends the cancellation and nothing else.

  • A correction that cannot be applied opens a reconciliation item instead of disappearing. Stripe documents that an event can only be cancelled “within 24 hours of Stripe receiving them”, so a late correction, or one for a period whose invoice has been observed finalized, becomes a quarantined item with quarantine_reason: "provider_window_closed" and one manual_adjustment row carrying the customer, meter, net quantity, period, original identifier and the adjustment a human has to make. Both writes happen in one transaction: a quarantine without the numbers is a credit nobody can make.

  • AuroraMeter.Pro.Outbox.Reconciler, a new Oban worker (a host must add it to its crontab) that compares local export receipts with Stripe’s own meter event summaries, error reports and invoices. It writes two things and nothing else: rows in aurora_meter_reconciliation_items, and the accepted -> confirmed transition for items a provider aggregate accounts for. It never touches a counter, an event, a usage report or a credit row. Every run is bounded by a request count, a group count and an elapsed-time budget, each checked before the thing it bounds, and it checkpoints so the next run continues rather than restarting.

  • Six reconciliation kinds, a 1.0 promise an operator can script against: manual_adjustment, summary_shortfall, summary_excess, meter_error_report, unconfirmed_item and invoice_status. One finding is one row however many times it is found, enforced by the identity index.

  • AuroraMeter.Pro.Reconcile.list_items/1 and resolve_item/3, so an operator can see and close a finding. Resolving requires a non-empty resolution, is conditional on the item still being open, changes nothing else, and writes an aurora_meter_recovery_actions row naming the actor.

  • The meter error report is received, when a host has subscribed to it. v1.billing.meter.error_report_triggered and v1.billing.meter.no_meter_found are thin events on Stripe’s v2 event stream, so they arrive only at an event destination created with the Thin payload style. AuroraMeter.Pro.Webhook records the sanitised body on the event row and the reconciler opens findings from it; docs/reconciliation.md says plainly that Stripe samples, so the identifiers it names are a sample and its error count is the number that matters.

  • Twelve configuration keys: :stripe_meter_ids (the meter id per feature, which is a different value from :stripe_meters‘ event name and is what a summary read takes), :adjustment_window_seconds, :confirmation_delay_seconds, :confirmation_grace_seconds, :reconciler_max_requests, :reconciler_max_groups, :reconciler_time_budget_ms, :reconciler_check_invoices, :reconciler_error_report_batch, and the internal :stripe_adjustment_client, :stripe_summary_client and :stripe_invoice_client. Their accessors: stripe_meter_ids/0, meter_id_for/1, adjustment_window_seconds/0, confirmation_delay_seconds/0, confirmation_grace_seconds/0, reconciler_max_requests/0, reconciler_max_groups/0, reconciler_time_budget_ms/0, reconciler_check_invoices?/0 and reconciler_error_report_batch/0.

  • Three telemetry events: [:aurora_meter, :pro, :correction] per correction delivery, [:aurora_meter, :pro, :reconciliation, :difference] per finding opened, and [:aurora_meter, :pro, :reconciliation, :run] per reconciler run.

  • Three correction quarantine reasons, all meaning “this credit has to be made at the provider by hand”: :original_not_exported, :correction_chain_exhausted and :correction_chain_unresolved.

  • docs/reconciliation.md: what each finding kind means, what the four provider states mean, what reconciliation can and cannot prove, and how to resolve an item.

  • AuroraMeter.Pro.Recovery, the guarded replacement for the console recipes docs/recovery.md used to carry. Ten functions: list_uncertain/1, inspect_item/1, actions/2 and pending_payments/1 read and write nothing; acknowledge_item/2, abandon_item/2, requeue_item/2, attach_payment_intent/2, abandon_payment/2 and reschedule_cutover/2 mutate. Every mutation takes the subject first and one keyword list second carrying expected: (the state the operator believes they are acting on), reason: and evidence:, compares that claim against the row before it writes, and writes one aurora_meter_recovery_actions row whether it applied or refused. There is no force: option and there never will be. Nothing in the module calls a provider: the one outbound call reachable from it is the existing GET-only payment resume, which cannot create a charge.

  • mix aurora_meter_pro.recovery, for a host whose production nodes take no remote console. Read-only by default; a mutating subcommand exits non-zero without --reason and --evidence, and without --yes it prints the row, the change it would make and the expected-state map it would send, and writes nothing. --format json so a run can be pasted into an incident record.

  • Configuration key :recovery_actor and AuroraMeter.Pro.Config.recovery_actor/0, defaulting to the node name. A host that fronts recovery with its own admin screen passes :actor per call instead.

  • Telemetry [:aurora_meter, :pro, :recovery, :action], one event per recovery action, applied or refused.

  • docs/recovery.md rewritten around the API. The two console recipes are removed, not supplemented: one wrote with repo.update! after asserting in memory, which bypasses every conditional predicate the outbox rests on, and the other took the global reporter lock, which the reporter has not held since the outbox landed, and edited a row that no longer drives delivery. The judgement the old page carried is kept word for word, including that a timeout is not evidence of rejection, that an empty search is not proof of no charge, and that an adjustment must never be represented as a meter report that did not occur. The page also now states the limit plainly: the software cannot verify an operator’s claim, acknowledging wrongly under-bills, abandoning wrongly double-bills, and an operator who is unsure should leave the item uncertain.

  • AuroraMeter.Pro.cron_entries/1, the recommended crontab for every scheduled Pro worker, to sit beside AuroraMeter.Oban.cron_entries/1.

  • mix aurora_meter_pro.install, which writes the :aurora_meter_pro configuration as {:system, "VAR"} placeholders, adds AuroraMeter.Pro to the supervision tree, generates the migration and adds the cron entries. It writes no secret, contacts nothing, refuses to schedule both expiry workers, and a second run changes nothing. {:igniter, "~> 0.8", optional: true} is the new optional dependency behind it.

  • AuroraMeter.Pro.Credits.reconciliation_required?/1: whether an account’s pending payment can only be resolved by a person.

  • Telemetry [:aurora_meter, :pro, :credits, :gauge] with armed, below_threshold, enqueued and reconciliation_required, once per sweep.

  • Pause, resume and cursors on every scheduled worker, through the free AuroraMeter.Operations. AuroraMeter.Operations.pause("usage_reporter:global") stops the reporter at its next batch boundary with no deploy.

Changed

  • The dashboard’s money section reports four figures rather than one balance: Spendable, On hold, Owed and Expired, each a formatted money string with its own visible label, followed by spend this period and runway. The headline figure is spendable rather than available, because what a tenant is asking is what a hold or a debit would be allowed to take, and on a wallet cut over to credit lots the two differ by expired credit and by debt. A zero renders as $0.00 rather than hiding its row, which “On hold” used to do: a section whose rows move between tenants is harder to read than one with a zero in it. A non-zero debt carries the word “Owed” and an aria-label giving the amount and the state; the colour class on it is emphasis and carries no meaning of its own (invariant I20).

  • The money section renders against a core that does not have the four figures, showing the three it has always shown. AuroraMeter.Credits.summary/1 gained spendable, promotional_spendable, debt and expired in core 0.6.0, and a Pro release must not require a core release that is not out yet.

  • AuroraMeter.Pro.Credits.on_low_balance/1 is unchanged, and it is worth saying what changed around it: core no longer runs it inside the writer. Pro’s payment paths hold a pinned connection through AuroraMeter.Pro.Lock.with_lock/2, and a handler that reads the database inside that window needed a second connection the caller was waiting to release. Nothing in Pro moved; two tests that had been passing while the handler failed now assert that it finished.

  • AuroraMeter.Pro.Dashboard.money?/1 also considers debt and expired. A settlement above its hold leaves a wallet at balance: 0, debt: 3_000_000, and hiding the section there hid the one figure the tenant needed to see.

  • AuroraMeter.Pro.UsageReporter.report_all/0 no longer sends anything. Its signature is unchanged and it still returns :ok | {:error, reasons}, but the usage reaches Stripe when the deliverer next runs rather than inside the call. A host that needs an immediate send runs report_all/0 and then Oban.insert(AuroraMeter.Pro.Outbox.Deliverer.new(%{})). The global advisory lock the old run held across every network call is gone: two overlapping runs are safe because staging is idempotent on the item’s identity, not because a lock prevents the second one.

  • A rate-limited usage report is retried under the same identifier. The old reporter read HTTP 429 as a definite rejection, cleared the staged payload, and let the next run send the same usage under a new identifier. If the rate-limited request had in fact been ingested, that was a double bill. Work that exhausts its attempts or passes the idempotency horizon now becomes uncertain and queryable, and never rejected.

  • The reporter reads subscriptions and counters through the core’s public API (AuroraMeter.Storage.list_subscriptions/2, new in core, and load_counter/3) instead of querying AuroraMeter.Schema.Subscription and AuroraMeter.Schema.Counter directly. A run is now bounded by :reporter_batch_size pages rather than loading every subscription at once.

  • AuroraMeter.Pro.Credits.with_lock/2 is deprecated and delegates to AuroraMeter.Pro.Lock.with_lock/2. AuroraMeter.Pro.Stripe.report_usage/1 is deprecated: it remains an AuroraMeter.Billing.Provider callback and now goes through the exporter, but it has no retry, no horizon and no record of what it sent. Stage through the outbox instead.

  • AuroraMeter.Pro.Credits.AutoTopUpWorker classifies provider answers with the shared taxonomy. Two changes come with it, both narrowing what counts as definite: a Stripe error carrying a code but no HTTP status is now indeterminate, and an SDK-internal refusal to build the request is now definite. The worker’s max_attempts: 1 is unchanged and still belongs to a later unit; the pending idempotency key is kept either way.

  • Every clock read goes through AuroraMeter.Clock. Alerts, the audit log and its plug, components, credits, the reconciler, rollups, the audit event schema, the usage reporter and the webhook all take their instants and dates from the configured clock, so a host can freeze time in its own tests.

  • The auto top-up cooldown and the 23 hour uncertainty horizon are decided by the database clock. last_attempt_at and pending_started_at are stamped by Postgres with clock_timestamp(), and the comparisons take Clock.db_now/0, so both sides of each come from one clock. Before this, one node stamped the row and, in a cluster, another compared against it: with any cooldown shorter than the clock skew, an account could sit in cooldown until the clocks converged and quietly stop topping up.

  • Two comparisons stay on the node clock on purpose, because the other side of each is a timestamp Stripe generated and no shared clock exists: the webhook signature tolerance, and the card-recency check whose payment_method_saved_at comes from Stripe’s created.

  • AuroraMeter.Pro.Period‘s current/2 checks the lower bound of the subscription window. A window starting ahead of the clock used to be returned even though it did not contain the current instant, which core 0.5.0’s validated period read refuses.

  • AuroraMeter.Pro.Reconcile.run/1 returns a map, not a list. The list is unchanged under the :local_drift key, and :items_open counts the findings waiting for a human. A host that pattern-matched the list changes one line. The local check itself now reads counters through AuroraMeter.Storage.load_counter/3 rather than joining AuroraMeter.Schema.Counter directly, so a host running its own storage adapter is reconciled against the state it actually uses.

  • The rollup attributes a billing period to the month containing its midpoint, for any period source other than AuroraMeter.Period.Calendar. A subscription that renews on the 25th produces a period from 25 August to 25 September, and date_trunc('month', period_start) reported twenty-five days of September usage as August’s. A calendar-aligned installation is unchanged. Existing rollup rows are not rewritten; the next run recomputes them, so a dashboard will show numbers move once.

  • Day rollups from durable events bucket on occurred_at, not on the write instant, and subtract corrections. Core schema version 7 separated the two and a backdated event was being reported on the day it was recorded. They now read through AuroraMeter.Storage.stream_events/2 in keyset pages rather than querying AuroraMeter.Schema.Event directly.

  • AuroraMeter.Pro.Cutover.schedule/3‘s :already_scheduled refusal now has somewhere to point. AuroraMeter.Pro.Recovery.reschedule_cutover/2 moves a watermark under an expected-state map, and refuses once anything has been billed at or after the earlier of the two watermarks, because moving one past reported work reassigns a period from one reporting path to the other.

  • AuroraMeter.Pro.Reconcile.resolve_item/3 writes its audit row through the same writer every other recovery action uses, so closing a finding now emits the recovery telemetry event and carries an actor by default.

  • A Stripe payment can no longer credit a tenant on a customer this installation cannot stand behind (gate G04 bullet 7, open-findings.md X171). Until now the tenant came from metadata.tenant_key and the customer was never checked: measured against the real Stripe test account, a correctly signed payment_intent.succeeded naming cus_doesNotExist000000, a tenant this installation had never seen and a reference that had never granted answered 200 and granted 2500 cents. The customer read failed with resource_missing deeper in, was logged as a warning, and the grant went ahead. checkout.session.completed and payment_intent.succeeded now refuse a customer that is not in this installation’s Stripe account ({:refused, :unknown_customer}) and one already on record against a different tenant ({:refused, :customer_belongs_to_another_tenant}). Both answer HTTP 200, because a redelivery would be refused the same way, and both are logged at error naming the tenant and the customer; docs/recovery.md carries the procedure for completing a payment that was refused and turns out to be genuine.

    What is not refused, deliberately: a tenant moving between its own Stripe customers (a second checkout, a subscription customer), and an event that names no customer at all, which is a shape Stripe really sends. A provider that cannot be reached is {:error, :customer_unverified} and an HTTP 500, so Stripe redelivers the payment instead of it being credited on a customer nobody confirmed. The check costs one Stripe read on a tenant’s first payment and none afterwards, because a customer already on the tenant’s account is verified from this installation’s own records.

  • AuroraMeter.Pro.Credits.handle_event/1 can return {:refused, reason}, a third answer beside {:ok, _} and {:error, _}: this will not be applied, and sending it again will not change that. AuroraMeter.Pro.Webhook answers it with 200 and leaves the event unapplied, so it stays visible as unfinished work and a deliberate stripe events resend after the record is corrected still runs the handler.

  • AuroraMeter.Pro.Credits.StripeClient gained retrieve_customer/1. A host that has substituted its own client module must implement it. AuroraMeter.Pro.Credits.StripeClient.Fake gained put_customer/2, which says what retrieve_customer/1 answers for one id (every customer exists until one says otherwise), and no_such_customer/1, which is Stripe’s own 404 in the shape stripity_stripe produces it: code: :invalid_request_error and extra.card_code: :resource_missing, measured against the real test account, because a test that refuses on an invented shape is not refusing for Stripe’s reason.

  • AuroraMeter.Pro.Credits.Expirer is deprecated, and is now a shim for AuroraMeter.Oban.CreditExpiry, which ships in the free core under MIT. This worker only ever existed because the core had no scheduler; it now has one, doing the same thing by calling the same public operation, so paying for Pro is no longer part of expiring promotional credit on a schedule.

    Nothing breaks. The module keeps its name, its queue, its max_attempts, and both run/0 and perform/1 return exactly what they returned before. run/0 carries @deprecated, so a call to it warns at compile time and names the replacement, and the module is removed in 2.0. Move the crontab entry from {"*/30 * * * *", AuroraMeter.Pro.Credits.Expirer} to {"*/30 * * * *", AuroraMeter.Oban.CreditExpiry}, and do not register both: AuroraMeter.Oban.validate!/1 refuses a crontab that does, because the sweep would run twice for one result.

    perform/1 no longer writes {:ok, _count} = run(). That hard match turned any future error return into a MatchError that crashed the job instead of failing it; it now maps every shape onto an Oban result.

  • The published crontab was wrong in three ways and is now one list. docs/getting-started.md scheduled the usage reporter hourly while docs/usage-reporting.md scheduled it every five minutes, and the two had disagreed since 0.4.0. Five minutes is correct, and the reasoning is in the new docs/operations/scheduler.md: on an hourly tick up to an hour of metered usage can be unstaged when Stripe finalises an invoice, and what stops two runs overlapping is the job’s uniqueness rather than the gap between ticks. The same guide omitted AuroraMeter.Pro.AuditLog.Pruner, so a host following the canonical setup never pruned the one table that grows with traffic, and omitted AuroraMeter.Pro.Outbox.Deliverer and AuroraMeter.Pro.Outbox.Reconciler, which 0.4.0 added. All four are fixed and a test now compares the guide’s crontab with the scheduler map.

  • AuroraMeter.Pro.Credits.AutoTopUpWorker no longer discards a payment whose outcome nobody knows (open-findings.md P20). max_attempts is 5, an answer that changes with time snoozes, a guard cancels, a definite rejection is :ok with the failure on the account, and an uncertain outcome backs off five times and then cancels with :uncertain for AuroraMeter.Pro.Recovery. Jobs that previously appeared as discarded now appear as scheduled, cancelled or completed; a dashboard counting discards will see the number fall.

  • AuroraMeter.Pro.Credits.AutoTopUpSweeper no longer enqueues an account that requires reconciliation. It counts it in the new gauge instead. Before this, such an account produced one discarded job per sweep, for ever.

  • AuroraMeter.Pro.Alerts, Rollup and AutoTopUpSweeper declare unique, so a tick landing on a still-running job is deduplicated rather than overlapping.

  • AuroraMeter.Pro.UsageReporter.perform/1 returns a run report and no longer fails the job because one subscription could not be staged; the failure is counted and the run goes on. report_all/0 is unchanged.

  • AuroraMeter.Pro.AuditLog.Pruner.perform/1 no longer hard-matches run/1‘s return (L05a-1).

Deprecated

  • AuroraMeter.Pro.AuditLog.Pruner, in favour of AuroraMeter.Pro.Retention.Pruner. It keeps its name, its queue, its max_attempts, its %{"older_than_days" => n} argument, its pause name and its schedule, and it still prunes the audit log and only the audit log, so a host that has not moved its crontab entry gets the behaviour it had. It logs one deprecation line per run and is removed in 2.0. Do not register both: they sweep the same table, and AuroraMeter.Pro.cron_entries/1 returns only the new one.

Documentation

  • docs/operations/scheduler.md: every Pro worker with its schedule, attempts, uniqueness, job arguments and what happens if it runs twice, plus the reporter-schedule decision and its reasons. The free core carries the other half of the same map at the same path.

[0.3.0] - 2026-09-11

The first release carrying code since 0.2.0 — 0.2.1 and 0.2.2 were documentation and licence text. It brings prepaid credit top-ups, the request audit log and the credit-aware dashboard, together with a large body of money correctness found by auditing all of it against a live Stripe sandbox.

Requires aurora_meter ~> 0.4. Pro schema versions 2 through 9, and the core’s versions 3 through 6 — installs upgrading from Pro 0.2 add one migration of each (mix aurora_meter_pro.gen.migration -r MyApp.Repo --from 2 generates the Pro half, mix aurora_meter.gen.migration --from 3 the core’s):

def up do
  AuroraMeter.Migration.up(from: 3)
  AuroraMeter.Pro.Migration.up(from: 2)
end

def down do
  AuroraMeter.Pro.Migration.down(to: 2)
  AuroraMeter.Migration.down(to: 3)
end

Both are required: the core writes promotional_after on every ledger entry, and the Pro credit account needs pending_idempotency_key (6) and counted_failure_references (7) plus immutable pending payment/report payloads (8) for recovery and saved-card event ordering (9). Core schema 6 adds idempotent flush receipts. If upgrading from a later installed schema, start from the first missing version. Restart all core processes after upgrade: the in-memory counter tuple changed.

Release audit corrections

  • Reconcile refund events against the charge’s current Stripe total. Failed or cancelled refunds restore credit once; old successful snapshots cannot debit again. Subscribe to refund.created, refund.updated, and refund.failed. Current disputes are included in the same target so refund/dispute ordering cannot restore refunded credit. Custom credit Stripe clients must implement retrieve_charge/1 and list_charge_disputes/1.

  • Document OTP 26 as the Pro minimum and update the minimum-runtime CI job. The resolved Stripe HTTP stack requires it; core can still run on OTP 25.

  • Match reversal and reinstatement reference prefixes literally, so SQL wildcard characters cannot mix another payment’s entries into the cap.

  • Pending payments and meter reports retain immutable payloads for safe retries; unknown outcomes stop at 23 hours for reconciliation. Known PaymentIntents are retrieved even after that window or after recharge is disabled.

  • Account locks serialize charge eligibility, refunds, settings and failures. Failure count, reference and disable state commit in one update.

  • The reporter flushes and reads completed persisted usage, including final usage after cancellation. Unfinished quota callbacks cannot become charges. A fresh stage after definite rejection receives a fresh idempotency key.

  • Subscription webhooks retrieve current Stripe state through the configured provider instead of applying stale event snapshots. The Stripe credential therefore needs subscription read access; failures request redelivery.

  • Payment and Checkout currency must match the ledger. Cent-denominated top-ups reject zero/three-decimal Stripe currencies; no currency conversion is performed.

  • Dispute reinstatement is scoped to its own withdrawal, and replaying a reversed payment cannot restore the removed card.

  • Closed accounts remain closed after delayed successful payments. Persisted card-selection timestamps prevent older or duplicate successes from replacing a newer card or Stripe customer default. Tied timestamps preserve the current selection; explicit card edits establish a new ordering boundary.

  • Verified real Stripe test-mode checkout, recharge, declines, replay, refund, cancellation, renewal and final cancellation invoices. See audit evidence; provider receipt alone does not prove asynchronous aggregation or invoice payment.

Added

  • Prepaid credit top-ups — AuroraMeter.Pro.Credits.checkout/3 sells an amount of credit through a payment-mode Stripe Checkout Session (inline price_data against a product AuroraMeter.Pro.Credits.Product.ensure/0 finds or creates; the card is saved for off-session use). The webhook grants the same amount to the core ledger (AuroraMeter.Credits.grant/3) keyed on the PaymentIntent id, so checkout.session.completed and payment_intent.succeeded for the same payment fund the account exactly once. checkout_params/3 is public and pure. See ADR 0002.

  • Auto top-up — AuroraMeter.Pro.Credits.update_auto_top_up/2 stores a threshold and amount per tenant (AuroraMeter.Pro.Schema.CreditAccount, table aurora_meter_credit_accounts) and mirrors the threshold to the core’s per-tenant low-balance threshold. on_low_balance/1 is the core hook (config :aurora_meter, credits_low_balance_handler: &AuroraMeter.Pro.Credits.on_low_balance/1); it enqueues AuroraMeter.Pro.Credits.AutoTopUpWorker (Oban, queue :aurora_meter, unique per tenant), which re-checks the guards, charges the saved card off-session with an idempotency key, grants the credit and resets the failure count. After auto_top_up_max_failures consecutive failures the account is disabled with a reason; a later successful payment or re_enable_auto_top_up/1 clears it. Also account/1, customer_id/1, disable_auto_top_up/2, record_failure/2, record_payment/4, portal_url/2, presets_cents/0.

  • AuroraMeter.Pro.Credits.Expirer — an Oban worker for AuroraMeter.Credits.expire_due/1 (the core has no scheduler).

  • AuroraMeter.Pro.Credits.StripeClient — the slice of Stripe the top-ups use, as a behaviour; .Live (stripity_stripe) and .Fake (records calls, forces failures) implementations. Tests never touch the network.

  • Host notifications through config :aurora_meter_pro, credits_handler:: {:granted, txn} (once per payment), {:low_balance, event}, {:auto_top_up_failed, tenant_key, reason, count} and {:auto_top_up_disabled, tenant_key, reason}. Telemetry [:aurora_meter, :pro, :auto_top_up] with result metadata.

  • Config: credits_stripe_client, credits_product_id, credits_product_name, credits_currency, credits_presets_cents, credits_min_cents, credits_max_cents, credits_handler, auto_top_up_cooldown_seconds, auto_top_up_max_failures.

  • AuroraMeter.Pro.Migration is versioned like the core: up/1 and down/1 take :from / :to / :version, latest_version/0 is 9, and every version is idempotent (create_if_not_exists). up/0 and down/0 still run everything. mix aurora_meter_pro.gen.migration --from N.

  • Request audit log — AuroraMeter.Pro.AuditLog records one row per API call (aurora_meter_audit_events, Pro schema version 3): actor, mode, method, path, route, status, duration, request id, ip, user agent, the money attributable to the call and free-form metadata. record/1 (never raises, never blocks), list/2 (filters on status, status class, method, path prefix, mode and a time range; keyset cursor paging on (inserted_at, id) with :limit capped at 200), stats/2 (totals by status class, p50/p95 duration), prune/1 and cursor/1.

  • AuroraMeter.Pro.Plugs.AuditLog — a plug that records on Plug.Conn.register_before_send/2. Everything host-shaped arrives as a resolver function of the conn (:tenant, :actor, :metadata), so the package never guesses at a host’s schema; a :tenant resolver returning nil skips the write, which is how unauthenticated requests stay out. A raising resolver or a failing write is a Logger.warning, never an error in the request being audited. See ADR 0003.

  • AuroraMeter.Pro.AuditLog.Pruner — Oban worker (queue :aurora_meter) for the retention sweep, deleting in bounded batches.

  • Config: audit_log_retention_days (default 30) and audit_log_writer (:async default, a task under AuroraMeter.Pro.TaskSupervisor; :sync for tests).

  • Credit-aware dashboard — AuroraMeter.Pro.Dashboard.load/2 also returns :credits (AuroraMeter.Credits.summary/1), :spend (spend_history/2, daily) and :spend_months (monthly, 365 days), and usage_dashboard/1 leads with a money section: balance, spend this period, runway and the core’s spend_chart/1. Dashboard.money?/1 guards it, so a product with no credit ledger renders exactly as it did in 0.3. load/2 takes credits: false and usage_dashboard/1 takes money={false} to opt out; AuroraMeter.Pro.Components.money_section/1 is public.

  • AuroraMeter.Pro.Credits.AutoTopUpSweeper — the level-triggered pass.

  • AuroraMeter.Pro.Credits.close_account/1, and with_lock/2 made public so that hosts can serialise their own work on the same tenant key the webhook handlers already use.

  • AuroraMeter.Pro.AuditLog.any?/2.

  • Schema versions 4–7: an inserted_at index for the audit-log pruner, last_failure_reference, pending_idempotency_key and counted_failure_references on aurora_meter_credit_accounts.

  • The fake Stripe client answers a repeated idempotency key with the intent it made the first time, as Stripe does — without which a test could claim that protection while never exercising it — and hold_payment_intents/1 holds them at a chosen status so a host can exercise the delayed-notification path.

Changed

  • Webhook answers 500 when a handler returns {:error, reason} (it was 200), so Stripe retries the event instead of dropping it — a top-up whose grant failed on a transient database error would otherwise be lost. Unhandled events are still 200.

  • A payment-mode checkout.session.completed is routed to AuroraMeter.Pro.Credits instead of Subscriptions.sync_from_session/1, which would have upserted a bogus "unknown"-plan subscription and demoted the tenant. Subscription-mode sessions behave as before.

  • The dashboard no longer describes counted requests as billable overage. A :counter feature (aurora_meter 0.4, ADR 0006) used to reach quota_card/1 as a metered feature with included: 0 and render 6 / 0 with “6 over the included allowance · billed at period end”. It now renders the bare count, no cap and no progress bar, and the status line "6 this period · paid from credits" — for a prepaid product nothing is billed at period end, because the money left the ledger when the request ran. level/1 never marks a counter as a warning. Hosts asserting on the old wording will need to update.

  • A :metered feature with a unit_price above zero keeps the overage wording unchanged — other Aurora products genuinely do bill it. A metered feature priced at zero now drops the “· billed at period end” half of the sentence, which was never true for it.

  • Dashboard.load/2 returns :counter quotas in :quotas (with a daily chart) rather than dropping them into the feature chips.

  • record_failure/2 gains an optional reference, update_auto_top_up/2 an options argument (clear_stop:), Reconcile.run/0 an options argument, and AutoTopUpWorker.attempt/2 an optional idempotency key. The existing arities still work.

  • The dependency on the core is resolved from the sibling checkout when there is one and from Hex otherwise, so the package can be published from inside the workspace with AURORA_METER_FROM_HEX=1.

  • Test-database migrations are pinned to the version they add, so a database built incrementally and one built fresh end up with the same schema.

Fixed

The recurring shape of these, and the reason there are so many: an event handler that answers 200 when it should not. Stripe marks such an event delivered and never sends it again, so the money moves and the ledger never hears — and nothing downstream notices, because the reconciler compares usage reports against counters and never looks at the credit ledger.

None of it affects a published version. Credit top-ups, the webhook handlers and the audit log are all new here; the behaviour is recorded because it is worth knowing, not because 0.2.2 carried the bug.

Auto top-up

  • A timeout or a 5xx is no longer counted against the card. It says nothing about whether a card works, and it was counted the same as a decline — so a few minutes of Stripe being unwell disabled auto top-up for every customer whose balance happened to be low, with a reason naming their card. Failures are classified by whether Stripe’s answer settles what happened to the money: a network failure, a 5xx, a 429 and anything unrecognised are indeterminate; only a rejection is definitive.
  • A processing PaymentIntent is a skip, not a failure. That is the ordinary answer for a delayed-notification method — ACH, SEPA, BECS — which Checkout is free to save off-session. Treating it as a failure cleared the idempotency key and started a second debit for the same top-up as soon as the cooldown lapsed, and a third after that.
  • The idempotency key is written to the account before the card is charged (schema version 6). The previous key was the tenant, the amount and the cooldown window — but the cooldown decides when a retry is allowed at all, so any retry it permits is in a later window and gets a different key: the guard and the key cancelled out exactly. No key derived from the attempt can work, because the next attempt is a different attempt. An attempt that died after Stripe took the money now retries under the same key, Stripe answers with the original PaymentIntent instead of charging again, and the missing grant lands.
  • One decline is counted once. The worker recorded its own declines with no reference, so Stripe’s payment_intent.payment_failed for the same intent counted it a second time; and a single remembered reference was not enough, because Stripe retries a non-2xx delivery for days and a redelivery arriving after a second intent had taken the slot was counted all over again. The last twenty references are remembered (schema version 7).
  • The failure-count increment moved into the database, and count_failure and disable no longer write the number straight back from the struct they were handed — which was the stale read the increment existed to avoid.
  • A deliberate stop survives. Auto top-up is switched off when a refund or chargeback lands, and two things lifted it silently: a successful manual top-up ran reset_failures, which cleared disabled_reason whatever it said; and any settings edit took the enabling branch, because get_field/2 falls back to the stored value, so a refunded tenant re-armed their card by nudging the threshold slider. re_enable_auto_top_up/1 is the only way to ask now.
  • Auto top-up is level-triggered by a sweeper as well as edge-triggered by the low-balance hook, so an account that crossed the threshold while disabled, or during an outage, is not stranded below it.
  • A customer change that would leave the account with no saved card is refused. A redelivered older checkout session rewrote the Stripe customer backwards and wiped the card with it, stopping auto top-up for an owner who could see a perfectly good card in the portal.

Refunds, disputes and reinstatements

  • A dispute’s amount is its own; a refund’s is a running total. The handler subtracted one combined total from whichever arrived, so a dispute following a larger refund computed zero, answered 200, and the money left with the credit untouched. Totals are counted per kind, the whole grant is still the cap across both, and the reference carries the dispute’s own id so two withdrawals of the same amount on one charge are not read as a redelivery.
  • Winning a dispute no longer makes the payment unreversible. The withdrawal debit stayed in the running total, so the charge looked fully reversed: a goodwill refund afterwards computed nothing left to take and was dropped, and a re-opened dispute was swallowed identically. Reinstatements net out of the dispute total and the overall cap — not the refund total, where they made a redelivered refund read as though less had been taken and take it again.
  • A reinstatement is capped at what disputes actually took, rather than handing back every reversal on the charge.
  • A reversal that arrives before its own grant is refused, not ignored. Stripe retries each event on its own backoff and Radar’s early-fraud auto-refunds land within minutes, so a refund overtaking its payment_intent.succeeded is real. Answering :ignore returned 200 and the event was never redelivered. A charge that carries our tenant metadata and has no grant yet is refused for an hour, which is Stripe’s cue to retry; a charge naming no tenant of ours is still ignored. A dispute gets the same treatment through a different route — Stripe copies no metadata onto a Dispute, so ownership could not be read from it at all — as does a reinstatement that beats the withdrawal it undoes.
  • Auto top-up is disabled before the reversal is written, so the balance drop cannot fire the low-balance hook and enqueue a charge on an account that is about to be stopped.

Subscriptions

  • A stale event no longer resurrects a cancelled subscription. Stripe delivers every event independently and the upsert replaces every column, so a redelivered updated simply won. An updated for a subscription that has already ended is ignored unless it carries a later period, which is what a real renewal looks like; sync_from_session/1 gained the same guard, which matters because it hard-codes status: "active".
  • The cross-subscription guard was far too broad and is now narrow. It judged a late event by whether its period ended later, which is false for the ordinary cases: an annual-to-monthly downgrade ends sooner, a cycle anchored to the old renewal date is equal, and a checkout session carries no period bounds at all — so every new subscription bought by a tenant who already had one was discarded with a 200. Only a subscription that has ended is refused.
  • Both sync/1 and sync_from_session/1 take an advisory lock on the tenant for the read and the write. Deciding staleness by reading and then blind-upserting is exactly what concurrent delivery defeats.
  • sync_from_session/1 no longer writes NULL period bounds, which dropped the meter back to calendar months and gave a customer who subscribed on the 20th a second full allowance on the 1st.

Usage reporting

  • The ledger advances before the provider send, not after. One of the two goes second whatever the order; with the write second, a failure left last_reported where it was while Stripe already had the delta, and the next run billed the overlap again. Losing a delta once is the better error.
  • It rewinds only when the entry was definitely not ingested. rejected?/1 had been copied from the auto top-up worker, where the safe default is inverted: for a charge, “unknown” means do not charge again; for a usage report, “unknown” means do not report again and the cost is revenue given away. A 429 is not unknown — Stripe rate-limits at the edge — so it rewinds; only a 5xx and a dropped connection keep the ledger advanced.
  • The identifier names the whole window rather than just where it ends, so an identical retry deduplicates at the provider.
  • The reporter has Oban uniqueness. Every subscription it walks is a read of a live counter, a network call and a write back, in that order, so two overlapping runs both sent a delta from the same starting point and the provider added them up.

Webhook, audit log and dashboard

  • An oversized request body is a 413, not a 500. read_body/2 answers {:more, _, _} for anything over its chunk size and the plug matched only {:ok, _, _}, so any large enough request raised before any signature was checked, from anyone who could reach the endpoint.
  • The webhook accepts more than one signing secret, so a rotation does not drop events on the floor.
  • Audit rows are clamped by code points, which is what varchar(255) counts. String.length/1 counts graphemes, so a user agent of combining marks passed the guard, overflowed the column, raised, and was swallowed by the rescue — a way for a caller to keep their own requests out of the audit log.
  • set_admin/3 authorizes through authorize/1 rather than checking Scope.admin? on the mount-time scope. It is the one function of the lot that hands out admin, so a demoted admin’s first move from an open tab was to give it back to themselves.
  • The alert dedup rollback — dropping the “already sent” row when the handler reports a failure — is exercised by a test for the first time.

[0.2.2] - 2026-09-08

Documentation and licence text only; no code changes.

Changed

  • Aurora Meter Pro now has its own home at https://aurorameter.com. The package links, README and NOTICE point there for pricing, docs and the Pro preview, and keep the PhxTemplates links for licence terms and the templates.
  • LICENSE.commercial v2.1: the purchase URL is https://aurorameter.com/pricing and clause 7 states that the free trial is claimable once per customer. No change to the grant, per-application terms or refunds.

[0.2.1] - 2026-09-07

Compatible with aurora_meter ~> 0.2 or ~> 0.3. No migration.

Changed

  • Reconciliation compares against flushed totals. AuroraMeter.Pro.Reconcile now reads AuroraMeter.Storage.load_counter/3 instead of the node-local ETS view. Core 0.3 makes counters cluster-wide by re-basing each node on the persisted total, so a node that had not yet seen another node’s increments could otherwise report false drift.
  • Dependency widened to accept core 0.3 (cluster-wide counters, installer, AuroraMeter.Test). The usage reporter is unaffected: it already ships deltas against a ledger, and the database rows it settles from are now true cluster totals.

Licensing

  • LICENSE.commercial rewritten for per-application licensing (DR-011 on phxtemplates.com): one licence per distinct application with unlimited nodes, staging and production servers; the quantity on the subscription is the number of licensed applications; the Unlimited plan covers any number of applications owned by the licensed organisation. Existing subscribers on the 2026-07 unlimited-apps plan are unaffected.

[0.2.0] - 2026-09-07

Requires aurora_meter ~> 0.2 (run its schema-version-2 migration).

Added

  • Metered prices at checkout — config :aurora_meter_pro, stripe_metered_prices: %{plan => [price_id]}; AuroraMeter.Pro.Stripe attaches every flat price (quantity 1) and every metered price (no quantity) of the plan, so Stripe actually invoices the usage the reporter sends. stripe_prices may now list several flat prices per plan. checkout_params/2 is public and pure.
  • Plan from price — AuroraMeter.Pro.Config.plan_for_price/1; Subscriptions.sync/1 resolves the plan from the subscription’s item prices before falling back to checkout metadata, so plan changes made in the Stripe billing portal are honoured.
  • Previous-period settlement — the usage reporter also reports the unreported remainder of closed periods it has seen before (70-day lookback) with a timestamp just before the current period began, so usage between the last run and a rollover is billed to the right period. Periods it never saw (pre-subscription usage) are left alone; each run records the current period as seen.
  • Styled dashboard — usage_dashboard/1 is now a self-contained, themeable dashboard: per-feature quota cards with progress bars and status lines, inline SVG daily charts (usage_chart/1), feature chips and a pivoted monthly history table. AuroraMeter.Pro.Dashboard.load/2 returns everything it needs so LiveViews re-render on usage broadcasts (the mountable AuroraMeter.Pro.Live.Dashboard does).
  • Metered alerts — alerts fire for metered features against their included allowance too; payloads carry kind, percent, period_start, period_end.
  • AuroraMeter.Pro.Export.daily_csv/3.
  • Checkout options :customer_email and :allow_promotion_codes; an existing Stripe customer is reused.
  • stripe_portal_configuration (or portal_url(tenant, configuration: ...)) selects a Billing Portal configuration, since Stripe’s default portal often has plan switching and cancellation disabled.

Changed

  • Rollups aggregate in SQL and day buckets come from the core history table (durable events only when history: false); the day window is since_days: (default 90). Rollup.rollup_all/1 takes options.
  • Reporter and alerts consider trialing and past_due subscriptions, not only active; AuroraMeter.Pro.Period ignores non-entitled or expired windows.
  • Config no longer creates atoms from webhook input (String.to_existing_atom).

Fixed

  • Subscriptions.sync/1 reads current_period_start/end from the first subscription item when the API version no longer puts them on the subscription, and accepts an expanded customer object.
  • The webhook plug halts the connection and answers 400 to malformed JSON instead of raising.
  • The dashboard LiveView ignored usage broadcasts (no assign changed, so nothing re-rendered).

[0.1.1] - 2026-07-12

Fixed

  • A rejected usage report no longer advances the ledger (usage was silently lost).

[0.1.0] - 2026-07-11

Initial Pro release. Requires the free aurora_meter core.

Added

  • Stripe billing — AuroraMeter.Pro.Stripe implements AuroraMeter.Billing.Provider (Checkout, Billing Portal). A self-contained AuroraMeter.Pro.Webhook plug verifies Stripe signatures (HMAC-SHA256) and keeps subscriptions in sync via AuroraMeter.Pro.Subscriptions.
  • Usage reporting — AuroraMeter.Pro.UsageReporter (Oban) reports metered usage deltas to Stripe Billing Meters exactly once, via the aurora_meter_usage_reports ledger.
  • Subscription-aligned periods — AuroraMeter.Pro.Period.
  • Rollups & export — AuroraMeter.Pro.Rollup (month buckets from counters, day buckets from durable events); AuroraMeter.Pro.Export.usage_csv/2.
  • Dashboard — AuroraMeter.Pro.Components.usage_dashboard/1 and the mountable AuroraMeter.Pro.Live.Dashboard LiveView.
  • Quota alerts — AuroraMeter.Pro.Alerts fires [:aurora_meter, :alert] + an optional handler once per {tenant, feature, period, threshold}.
  • Reconciliation — AuroraMeter.Pro.Reconcile (local consistency check).
  • Installer — AuroraMeter.Pro.Migration and mix aurora_meter_pro.gen.migration.
  • Testing — AuroraMeter.Pro.Stripe.Fake (no network in tests).