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.
-
Run core’s migrations first, in full, to core schema 10. Pro’s version 10 reads tables core’s version 9 creates.
-
Generate and apply Pro’s:
mix aurora_meter_pro.gen.migration --upgrade -r MyApp.Repo -
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.Dashboardno longer acceptsparams["tenant"]. It readssession["tenant"]and ignoresparamsentirely, 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/0stages rather than sends. The outbox delivers, with retry, idempotency and a quarantine. Anything that calledreport_all/0and 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.00on a wallet that owes money. Nothing in Pro changed:AuroraMeter.Pro.Components.money_section/1renders whatAuroraMeter.Credits.summary/1reports, 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.00at the same time, which is the true reading: while a debt stands, everyhold/4anddebit/4behind that page is refused, with the new{:error, :debt_outstanding}. See core’sCHANGELOG.mdand the “Debt” section of itsdocs/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 exactAuroraMeter.Pro.Recovery.inspect_item/1call to paste, quarantined items grouped by reason, current leases, open reconciliation differences, pending payments with the age and whether it is pastuncertainty_horizon_seconds, the recovery audit trail and the provider mapping. Compiled only when the new optionalphoenix_live_dashboarddependency is installed.:authorized_byis required and has no default, and this page refusesauthorized_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 anevidence:string that a one-click button cannot supply honestly. A section it could not read renders “unavailable” with an error class, never a0and 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 byAuroraMeter.Pro.Telemetry.provider_span/2at the seam where Pro calls the configured client, so one span is one HTTP attempt and never the loop that retries it.operationis one ofAuroraMeter.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/0andevent_names/0return every Pro event as data, with its real measurement and metadata keys and the tags a metric may use;AuroraMeter.Pro.Telemetry.Metrics.metrics/1andgroups/0areTelemetry.Metricspresets over them, compiled only when the new optionaltelemetry_metricsdependency is installed. A host wanting both halves writesAuroraMeter.Telemetry.Metrics.metrics() ++ AuroraMeter.Pro.Telemetry.Metrics.metrics(). Newdocs/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 checksdocs/api.mdagainstlib/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
reasonmetadata 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_pricesand:stripe_metered_pricesare 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/1answers the pair,sync/1writes 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/3andstatus/2, and theAuroraMeter.Pro.Transitions.ApplierOban 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 aplan_change_rejectedreconciliation 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, forAuroraMeter.Subscriptions.preview_transition/3) andupdate_subscription_plan/3(the network call). -
Configuration
:proration_behavior(default:none) and:stripe_subscription_client(defaultStripe.Subscription). -
Telemetry
[:aurora_meter, :pro, :transition]and[:aurora_meter, :pro, :usage_report, :plan_fallback]. -
Two
AuroraMeter.Pro.Reconcileitem 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; andplan_change_rejected. -
Pro schema version 11:
plan_idandplan_versiononaurora_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. Pluspending_outcomeonaurora_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.Delivererdeclaredunique: [period: :infinity, states: <every incomplete state>], and that list contains:executing. A node killed mid-delivery leaves its jobexecutingand 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.mdX486).AuroraMeter.Pro.Outbox.DelivererandAuroraMeter.Pro.UsageReporterare now unique for 900 seconds andAuroraMeter.Pro.Outbox.Reconcilerfor 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*/5and 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_aftermust 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 realSIGKILL. -
mix aurora_meter_pro.gen.migrationworks. In every host before this it raised before it did anything. The task read the-rargument withMix.Ecto.parse_repo/1, which turns it into an atom, and never calledMix.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, calledrepo.config/0on 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.MigrationTestnow has adescribe "the migration the generator produces"block that gives the task a repo this VM has not loaded, asserts the refusal is a namedMix.Errorand never anUndefinedFunctionError, 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.UsageReporterno longer stages a buffered window for a feature whose reporting source is:events. The reporter decided from the cutover watermark alone. A feature declaredconfig :aurora_meter, feature_sources: %{tokens: :events}and never reported from the counter has no watermark, and atnilthe 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: oneaurora_meter_usage_reportsrow later, the next node to start raisedAuroraMeter.Pro.CutoverRequiredErrornaming a cutover the installation had never performed, and a host that followeddocs/operations/scheduler.mdbricked 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 whatdocs/usage-reporting.mdhas 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_sourcesstill says:bufferedand 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.installnow exits 1 when it refuses, and--dry-runprints 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--yessuppresses the diff, so--dry-runwrote 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.mdP9).sync_from_session/1hard-codedstatus: "active"for every completed session; it now derives the status frompayment_status, and a session that is complete but unpaid produces"incomplete", which is not an entitled status. Thecustomer.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
statusis refused rather than defaulted into an entitlement (the other half of P9).sync/1answeredto_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.AlertsandAuroraMeter.Pro.UsageReporterresolved the plan withAuroraMeter.Plans.get/1, which answers the version effective now, so both began reading version 2’s declarations the moment version 2’seffective_atpassed. Both now resolve the tenant’s own version (G07 bullet 1). -
AuroraMeter.Pro.Alertsno longer queriesAuroraMeter.Schema.Subscriptiondirectly: it walksAuroraMeter.Storage.list_subscriptions/2in pages, which also stopscheck_all/0loading every subscription in the installation into one list. Pro’slib/now builds no query against that table at all.
Changed
-
BREAKING: the optional
phoenix_live_viewrequirement 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_viewresolving to 0.19 or 0.20, your nextmix deps.getwill 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.ComponentsandAuroraMeter.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.exandpro/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_viewin your dependencies at all, nothing here affects you. Core’smix aurora_meter.install --check-supportanswers 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 afteraurora_meterwas built. -
BREAKING, and it is a security fix:
AuroraMeter.Pro.Live.Dashboardtakes the tenant from the session and from nowhere else.Before this release
mount/3readparams["tenant"] || session["tenant"], and the moduledoc advertised the routelive "/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.mdP8).What changed:
-
the tenant comes from
session["tenant"]only, andparamsare ignored entirely; -
a session with no
"tenant"key raisesArgumentErrorat 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 toAuroraMeter.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 endand 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
ArgumentErrorat 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. -
the tenant comes from
-
AuroraMeter.Pro.Config.plan_for_price/1returns{plan_id, version}rather than a bareplan_idatom, andnilis 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.mdX296). A host that pattern matched on a bare atom breaks loudly rather than silently. -
AuroraMeter.Pro.Subscriptions.plan_id/1is deprecated (removed in 2.0). It iselem(plan_ref/1, 0)and drops half of the contract. -
AuroraMeter.Pro.Stripe.checkout_params/2takes:versionand putsplan_versionin 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 throughAuroraMeter.Credits.reverse_lot/4andAuroraMeter.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.Reconcileitem kinds:reversal_without_grant, for a refund or dispute that cannot be placed and would otherwise have been answered 200 with no record, andcard_not_saved, for a payment whose credit is committed and whose card could not be saved. No migration: thekindcolumn is free text with an application-level list. -
Telemetry
[:aurora_meter, :pro, :credits, :wallet_wide_fallback](measurementcount, metadatatenant_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, andAuroraMeter.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, confirmedaurora_meter_outbox_items,aurora_meter_webhook_events,aurora_meter_recovery_actionsand resolvedaurora_meter_reconciliation_items.plan/1is a dry run that runs the prune’s own predicate withcount(*)and writes nothing;prune/1deletes in bounded batches with a per-run budget, and reports a paused or budget-exhausted table rather than failing.tables/0,protected/0andoperation/1expose the lists and the pause names, andAuroraMeter.Pro.Retention.Pruner‘soperations/0names 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_alertsandaurora_meter_source_cutoversare 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 ofprune/1. Both go throughAuroraMeter.Pro.Retentionnow, so the count is the prune’s own predicate rather than a second one that could drift, andprune/1gained 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, accessorwebhook_event_retention_days/0),:recovery_action_retention_days(365, accessorrecovery_action_retention_days/0) and:reconciliation_item_retention_days(365, accessorreconciliation_item_retention_days/0), joining the existingaudit_log_retention_days(30) andoutbox_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.UsageReportercomputes what is owed and stages one immutable row inaurora_meter_outbox_items, andAuroraMeter.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, anAuroraMeter.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. Itsdescribe/0carries Stripe’s documented limits, read on 2026-09-15 and recorded with their URLs indocs/evidence/v1/phase-04/stripe-meter-contract.md. -
AuroraMeter.Pro.Outbox, anAuroraMeter.Events.Outbox, so a durable event staged throughAuroraMeter.record/4gets its export intent inside the same transaction as the fact. An event this package cannot deliver is stagedquarantinedwith the reason, never dropped. -
AuroraMeter.Pro.Lock.with_lock/2, the advisory lock moved out ofAuroraMeter.Pro.Creditswith 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 indocs/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 withsource: :calendar_fallbackwhen it does not, so a caller can tell an answer from a guess. It implements the optionalcontaining/2callback that core 0.5.0 adds to theAuroraMeter.Periodbehaviour. -
AuroraMeter.Prois now something you put in your supervision tree, and it starts nothing.AuroraMeter.Pro.child_spec/1andAuroraMeter.Pro.start_link/1run 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!/0is the first check on its own, for a release health check that wants to fail a deploy without booting the app, andAuroraMeter.Pro.account_confirmed?/0reports whether the third one succeeded. -
The
:aurora_meter_proconfiguration 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_idis required once anything is mapped for export,:stripe_modemust agree with the API key’s prefix, a meter name must not be empty,:api_versionmust look like a Stripe version, and the{:system, "VAR"}behind a required key must actually be set.AuroraMeter.Pro.ConfigErroris 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/3records the period start from which a feature’s quantity comes from durable events instead of the buffered counter, andwatermark/1,status/1andlist/0read it. The reporter stages a buffered window only whileperiod_start < wandAuroraMeter.Pro.Outboxaccepts an event only whileperiod_start >= w, so a period belongs to exactly one path and never to both.usage_reports.sourcerecords which one it was, written once and never rewritten.mix aurora_meter_pro.cutoverschedules and inspects it, and refuses to guess a watermark. The whole procedure is in the cutover guide. -
Booting with a feature set to
:eventsthat has already been reported from the buffered counter, with no cutover, now fails.AuroraMeter.Pro.CutoverRequiredErrornames the feature, how manyaurora_meter_usage_reportsrows 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_eventsgets 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
livemodedisagrees with:stripe_mode, an event carrying nolivemode, 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 theStripe-VersionPro sends, written intoconfig :stripity_stripe, :api_versionat validation so an SDK upgrade cannot change the wire contract silently.:webhook_api_versionis 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_versionis 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/0andreporter_batch_size/0, plusvalidated/0andinvalidate!/0for 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_modedisagrees 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/4staged a correction item that had nowhere to go;AuroraMeter.Pro.Exporter.StripeMeterEventsnow delivers one, anddescribe/0lists:correctionin 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
quarantineditem withquarantine_reason: "provider_window_closed"and onemanual_adjustmentrow 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 inaurora_meter_reconciliation_items, and theaccepted -> confirmedtransition 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_itemandinvoice_status. One finding is one row however many times it is found, enforced by the identity index. -
AuroraMeter.Pro.Reconcile.list_items/1andresolve_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 anaurora_meter_recovery_actionsrow naming the actor. -
The meter error report is received, when a host has subscribed to it.
v1.billing.meter.error_report_triggeredandv1.billing.meter.no_meter_foundare thin events on Stripe’s v2 event stream, so they arrive only at an event destination created with the Thin payload style.AuroraMeter.Pro.Webhookrecords the sanitised body on the event row and the reconciler opens findings from it;docs/reconciliation.mdsays 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_clientand: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?/0andreconciler_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_exhaustedand: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 recipesdocs/recovery.mdused to carry. Ten functions:list_uncertain/1,inspect_item/1,actions/2andpending_payments/1read and write nothing;acknowledge_item/2,abandon_item/2,requeue_item/2,attach_payment_intent/2,abandon_payment/2andreschedule_cutover/2mutate. Every mutation takes the subject first and one keyword list second carryingexpected:(the state the operator believes they are acting on),reason:andevidence:, compares that claim against the row before it writes, and writes oneaurora_meter_recovery_actionsrow whether it applied or refused. There is noforce: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--reasonand--evidence, and without--yesit prints the row, the change it would make and the expected-state map it would send, and writes nothing.--format jsonso a run can be pasted into an incident record. -
Configuration key
:recovery_actorandAuroraMeter.Pro.Config.recovery_actor/0, defaulting to the node name. A host that fronts recovery with its own admin screen passes:actorper call instead. -
Telemetry
[:aurora_meter, :pro, :recovery, :action], one event per recovery action, applied or refused. -
docs/recovery.mdrewritten around the API. The two console recipes are removed, not supplemented: one wrote withrepo.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 itemuncertain. -
AuroraMeter.Pro.cron_entries/1, the recommended crontab for every scheduled Pro worker, to sit besideAuroraMeter.Oban.cron_entries/1. -
mix aurora_meter_pro.install, which writes the:aurora_meter_proconfiguration as{:system, "VAR"}placeholders, addsAuroraMeter.Proto 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]witharmed,below_threshold,enqueuedandreconciliation_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
spendablerather thanavailable, 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.00rather 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 anaria-labelgiving 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/1gainedspendable,promotional_spendable,debtandexpiredin 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/1is 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 throughAuroraMeter.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?/1also considersdebtandexpired. A settlement above its hold leaves a wallet atbalance: 0, debt: 3_000_000, and hiding the section there hid the one figure the tenant needed to see. -
AuroraMeter.Pro.UsageReporter.report_all/0no 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 runsreport_all/0and thenOban.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
uncertainand queryable, and neverrejected. -
The reporter reads subscriptions and counters through the core’s public API (
AuroraMeter.Storage.list_subscriptions/2, new in core, andload_counter/3) instead of queryingAuroraMeter.Schema.SubscriptionandAuroraMeter.Schema.Counterdirectly. A run is now bounded by:reporter_batch_sizepages rather than loading every subscription at once. -
AuroraMeter.Pro.Credits.with_lock/2is deprecated and delegates toAuroraMeter.Pro.Lock.with_lock/2.AuroraMeter.Pro.Stripe.report_usage/1is deprecated: it remains anAuroraMeter.Billing.Providercallback 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.AutoTopUpWorkerclassifies 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’smax_attempts: 1is 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_atandpending_started_atare stamped by Postgres withclock_timestamp(), and the comparisons takeClock.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_atcomes from Stripe’screated. -
AuroraMeter.Pro.Period‘scurrent/2checks 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/1returns a map, not a list. The list is unchanged under the:local_driftkey, and:items_opencounts the findings waiting for a human. A host that pattern-matched the list changes one line. The local check itself now reads counters throughAuroraMeter.Storage.load_counter/3rather than joiningAuroraMeter.Schema.Counterdirectly, 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, anddate_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 throughAuroraMeter.Storage.stream_events/2in keyset pages rather than queryingAuroraMeter.Schema.Eventdirectly. -
AuroraMeter.Pro.Cutover.schedule/3‘s:already_scheduledrefusal now has somewhere to point.AuroraMeter.Pro.Recovery.reschedule_cutover/2moves 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/3writes 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.mdX171). Until now the tenant came frommetadata.tenant_keyand the customer was never checked: measured against the real Stripe test account, a correctly signedpayment_intent.succeedednamingcus_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 withresource_missingdeeper in, was logged as a warning, and the grant went ahead.checkout.session.completedandpayment_intent.succeedednow 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 aterrornaming the tenant and the customer;docs/recovery.mdcarries 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/1can 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.Webhookanswers it with 200 and leaves the event unapplied, so it stays visible as unfinished work and a deliberatestripe events resendafter the record is corrected still runs the handler. -
AuroraMeter.Pro.Credits.StripeClientgainedretrieve_customer/1. A host that has substituted its own client module must implement it.AuroraMeter.Pro.Credits.StripeClient.Fakegainedput_customer/2, which says whatretrieve_customer/1answers for one id (every customer exists until one says otherwise), andno_such_customer/1, which is Stripe’s own 404 in the shapestripity_stripeproduces it:code: :invalid_request_errorandextra.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.Expireris deprecated, and is now a shim forAuroraMeter.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 bothrun/0andperform/1return exactly what they returned before.run/0carries@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!/1refuses a crontab that does, because the sweep would run twice for one result.perform/1no longer writes{:ok, _count} = run(). That hard match turned any future error return into aMatchErrorthat 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.mdscheduled the usage reporter hourly whiledocs/usage-reporting.mdscheduled it every five minutes, and the two had disagreed since 0.4.0. Five minutes is correct, and the reasoning is in the newdocs/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 omittedAuroraMeter.Pro.AuditLog.Pruner, so a host following the canonical setup never pruned the one table that grows with traffic, and omittedAuroraMeter.Pro.Outbox.DelivererandAuroraMeter.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.AutoTopUpWorkerno longer discards a payment whose outcome nobody knows (open-findings.mdP20).max_attemptsis 5, an answer that changes with time snoozes, a guard cancels, a definite rejection is:okwith the failure on the account, and an uncertain outcome backs off five times and then cancels with:uncertainforAuroraMeter.Pro.Recovery. Jobs that previously appeared asdiscardednow appear asscheduled,cancelledorcompleted; a dashboard counting discards will see the number fall. -
AuroraMeter.Pro.Credits.AutoTopUpSweeperno 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,RollupandAutoTopUpSweeperdeclareunique, so a tick landing on a still-running job is deduplicated rather than overlapping. -
AuroraMeter.Pro.UsageReporter.perform/1returns 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/0is unchanged. -
AuroraMeter.Pro.AuditLog.Pruner.perform/1no longer hard-matchesrun/1‘s return (L05a-1).
Deprecated
-
AuroraMeter.Pro.AuditLog.Pruner, in favour ofAuroraMeter.Pro.Retention.Pruner. It keeps its name, its queue, itsmax_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, andAuroraMeter.Pro.cron_entries/1returns 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, andrefund.failed. Current disputes are included in the same target so refund/dispute ordering cannot restore refunded credit. Custom credit Stripe clients must implementretrieve_charge/1andlist_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/3sells an amount of credit through apayment-mode Stripe Checkout Session (inlineprice_dataagainst a productAuroraMeter.Pro.Credits.Product.ensure/0finds 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, socheckout.session.completedandpayment_intent.succeededfor the same payment fund the account exactly once.checkout_params/3is public and pure. See ADR 0002. -
Auto top-up —
AuroraMeter.Pro.Credits.update_auto_top_up/2stores a threshold and amount per tenant (AuroraMeter.Pro.Schema.CreditAccount, tableaurora_meter_credit_accounts) and mirrors the threshold to the core’s per-tenant low-balance threshold.on_low_balance/1is the core hook (config :aurora_meter, credits_low_balance_handler: &AuroraMeter.Pro.Credits.on_low_balance/1); it enqueuesAuroraMeter.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. Afterauto_top_up_max_failuresconsecutive failures the account is disabled with a reason; a later successful payment orre_enable_auto_top_up/1clears it. Alsoaccount/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 forAuroraMeter.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]withresultmetadata. -
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.Migrationis versioned like the core:up/1anddown/1take:from/:to/:version,latest_version/0is9, and every version is idempotent (create_if_not_exists).up/0anddown/0still run everything.mix aurora_meter_pro.gen.migration --from N. -
Request audit log —
AuroraMeter.Pro.AuditLogrecords 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:limitcapped at 200),stats/2(totals by status class, p50/p95 duration),prune/1andcursor/1. -
AuroraMeter.Pro.Plugs.AuditLog— a plug that records onPlug.Conn.register_before_send/2. Everything host-shaped arrives as a resolver function of theconn(:tenant,:actor,:metadata), so the package never guesses at a host’s schema; a:tenantresolver returningnilskips the write, which is how unauthenticated requests stay out. A raising resolver or a failing write is aLogger.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) andaudit_log_writer(:asyncdefault, a task underAuroraMeter.Pro.TaskSupervisor;:syncfor tests). -
Credit-aware dashboard —
AuroraMeter.Pro.Dashboard.load/2also returns:credits(AuroraMeter.Credits.summary/1),:spend(spend_history/2, daily) and:spend_months(monthly, 365 days), andusage_dashboard/1leads with a money section: balance, spend this period, runway and the core’sspend_chart/1.Dashboard.money?/1guards it, so a product with no credit ledger renders exactly as it did in 0.3.load/2takescredits: falseandusage_dashboard/1takesmoney={false}to opt out;AuroraMeter.Pro.Components.money_section/1is public. -
AuroraMeter.Pro.Credits.AutoTopUpSweeper— the level-triggered pass. -
AuroraMeter.Pro.Credits.close_account/1, andwith_lock/2made 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_atindex for the audit-log pruner,last_failure_reference,pending_idempotency_keyandcounted_failure_referencesonaurora_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/1holds them at a chosen status so a host can exercise the delayed-notification path.
Changed
-
Webhook answers
500when a handler returns{:error, reason}(it was200), 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 still200. -
A
payment-modecheckout.session.completedis routed toAuroraMeter.Pro.Creditsinstead ofSubscriptions.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
:counterfeature (aurora_meter0.4, ADR 0006) used to reachquota_card/1as a metered feature withincluded: 0and render6 / 0with “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/1never marks a counter as a warning. Hosts asserting on the old wording will need to update. -
A
:meteredfeature with aunit_priceabove 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/2returns:counterquotas in:quotas(with a daily chart) rather than dropping them into the feature chips. -
record_failure/2gains an optional reference,update_auto_top_up/2an options argument (clear_stop:),Reconcile.run/0an options argument, andAutoTopUpWorker.attempt/2an 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
processingPaymentIntent 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_failedfor 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_failureanddisableno 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 cleareddisabled_reasonwhatever it said; and any settings edit took the enabling branch, becauseget_field/2falls back to the stored value, so a refunded tenant re-armed their card by nudging the threshold slider.re_enable_auto_top_up/1is 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.succeededis real. Answering:ignorereturned 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
updatedsimply won. Anupdatedfor 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/1gained the same guard, which matters because it hard-codesstatus: "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/1andsync_from_session/1take 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/1no 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_reportedwhere 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?/1had 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/2answers{: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/1counts 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/3authorizes throughauthorize/1rather than checkingScope.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.commercialv2.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.Reconcilenow readsAuroraMeter.Storage.load_counter/3instead 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.commercialrewritten 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.Stripeattaches every flat price (quantity 1) and every metered price (no quantity) of the plan, so Stripe actually invoices the usage the reporter sends.stripe_pricesmay now list several flat prices per plan.checkout_params/2is public and pure. -
Plan from price —
AuroraMeter.Pro.Config.plan_for_price/1;Subscriptions.sync/1resolves 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
timestampjust 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/1is 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/2returns everything it needs so LiveViews re-render on usage broadcasts (the mountableAuroraMeter.Pro.Live.Dashboarddoes). -
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_emailand:allow_promotion_codes; an existing Stripe customer is reused. -
stripe_portal_configuration(orportal_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 issince_days:(default 90).Rollup.rollup_all/1takes options. -
Reporter and alerts consider
trialingandpast_duesubscriptions, not onlyactive;AuroraMeter.Pro.Periodignores non-entitled or expired windows. -
Configno longer creates atoms from webhook input (String.to_existing_atom).
Fixed
-
Subscriptions.sync/1readscurrent_period_start/endfrom the first subscription item when the API version no longer puts them on the subscription, and accepts an expandedcustomerobject. -
The webhook plug halts the connection and answers
400to 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.StripeimplementsAuroraMeter.Billing.Provider(Checkout, Billing Portal). A self-containedAuroraMeter.Pro.Webhookplug verifies Stripe signatures (HMAC-SHA256) and keeps subscriptions in sync viaAuroraMeter.Pro.Subscriptions. -
Usage reporting —
AuroraMeter.Pro.UsageReporter(Oban) reports metered usage deltas to Stripe Billing Meters exactly once, via theaurora_meter_usage_reportsledger. -
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/1and the mountableAuroraMeter.Pro.Live.DashboardLiveView. -
Quota alerts —
AuroraMeter.Pro.Alertsfires[:aurora_meter, :alert]+ an optional handler once per{tenant, feature, period, threshold}. -
Reconciliation —
AuroraMeter.Pro.Reconcile(local consistency check). -
Installer —
AuroraMeter.Pro.Migrationandmix aurora_meter_pro.gen.migration. -
Testing —
AuroraMeter.Pro.Stripe.Fake(no network in tests).