Prepaid credit: top-ups, auto-recharge, refunds

The product. Parsely sells document parsing. No monthly fee: customers buy credit and each document spends some. When they run low, their saved card is charged automatically.

The core’s AuroraMeter.Credits holds the ledger (see Prepaid credits). This guide is the Stripe half: getting money into that ledger, and taking it back out when a payment is reversed.

1. Selling credit

def top_up(conn, %{"cents" => cents}) do
  org = conn.assigns.current_org

  {:ok, url} =
    AuroraMeter.Pro.Credits.checkout(org, String.to_integer(cents),
      success_url: url(~p"/billing?topped_up=1"),
      cancel_url: url(~p"/billing")
    )

  redirect(conn, external: url)
end

This is a payment-mode Checkout Session, not a subscription. The amount is in cents, and it is clamped to credits_min_cents and credits_max_cents so a crafted form cannot buy $0.01 or $1,000,000 of credit.

For the buttons on your page:

AuroraMeter.Pro.Credits.presets_cents()   # => [1_000, 2_500, 5_000, 10_000]

Tax on top

If your prices exclude tax, set credits_tax: :exclusive and set up Stripe Tax (a registration wherever you collect, and a preset product tax code). Then:

  • Checkout turns on automatic_tax, marks the price tax-exclusive, and requires a billing address, which is what Stripe Tax works from. It also offers the customer a field for a business tax ID. A Stripe customer from an earlier payment has its address and name updated from what they enter.
  • The ledger is credited with the credit, not the tax. A $25.00 top-up that Stripe taxes at 10% charges $27.50 and grants $25.00. The session and its PaymentIntent both carry credit_cents in their metadata, so whichever event arrives first grants the same amount (Credits.credit_cents/2).
  • Auto top-up asks Stripe Tax for a calculation against the saved customer before it charges, charges the calculated total, and attaches the calculation to the PaymentIntent, so Stripe records the tax when the charge succeeds, reverses it on a refund, and shows it on the receipt. If Stripe refuses to calculate (the customer has no address it can use), nothing is charged and the attempt counts as a failure with the reason :tax_calculation_failed. No answer at all is retried, like a timeout on the charge itself.
  • Refunds and chargebacks take back the credit’s share of what came back: half the charge refunded is half the credit, and a full refund takes the grant, not the grant and the tax.

With the default, :none, Stripe is never asked about tax and a customer pays exactly the credit they buy.

2. The credit lands from the webhook, not the redirect

The customer comes back to success_url and the balance may not have moved yet. Do not credit the ledger from that redirect. Anyone can visit a URL.

The grant happens when payment_intent.succeeded arrives, keyed on the PaymentIntent id:

Stripe: pi_3abc succeeded
  → grant(tenant, 25_000_000, reference: "stripe:pi_3abc")

Delivered twice, it grants once. Delivered a week late, it still grants once. That single property is what makes the whole thing safe, and it is why the reference is the PaymentIntent id and never a timestamp.

On your success page, say “your top-up is being confirmed” and let the live balance update itself. AuroraMeter.Credits.subscribe/1 pushes the new figure when the grant commits.

Which customer may fund which tenant

The tenant is named by metadata.tenant_key, and the Stripe customer on the event has to agree with it. A payment naming a customer this installation’s Stripe account does not have, or one already on record against a different tenant, grants nothing: it is answered 200 (a retry would be refused the same way), the reason is logged at error, and recovery.md says how to finish a payment that was refused and turns out to be genuine.

A tenant may still move between its own Stripe customers, which is what a second checkout and a subscription customer both do, and an event that names no customer at all is not refused either: it states no customer identity to disagree with. Only a missing customer or somebody else’s is refused. When Stripe cannot be reached to check, the payment is answered 500 and Stripe redelivers it rather than it being credited on a customer nobody confirmed. The check costs one Stripe read on a tenant’s first payment and none after that.

3. Auto top-up

AuroraMeter.Pro.Credits.update_auto_top_up(org, %{
  auto_top_up_enabled: true,
  threshold_micro: 5_000_000,    # when available drops below $5.00
  amount_cents: 2_500            # charge $25.00
})

The card is the one saved by their last successful top-up, charged off-session: no browser, no customer present.

Two things trigger it:

  • the ledger’s low-balance hook, which fires on a crossing, wired up by credits_low_balance_handler: &AuroraMeter.Pro.Credits.on_low_balance/1;
  • AuroraMeter.Pro.Credits.AutoTopUpSweeper, which catches accounts already sitting below the line.

You want both. The hook alone misses an account that crossed while auto top-up was switched off, or during an outage, and leaves it stranded below the threshold for ever.

Charging once, and never twice

This is the hardest part of the package, and worth understanding because the failure mode is charging a customer twice.

The original charge parameters, idempotency key and start time are written to the account before charging. An uncertain retry keeps exactly those parameters even if settings change. Once the PaymentIntent ID is known, recovery retrieves it rather than creating another charge. Its successful payment grants credit once, keyed on that ID.

An unknown outcome at least 23 hours old, or a legacy pending attempt missing its original parameters/time, returns :payment_reconciliation_required. The same applies when an unknown pending charge belongs to a disabled account. No new charge is created merely because Stripe’s idempotency window elapsed. The sweeper includes pending accounts even above their threshold or while disabled; known intents can still be resolved. Preserve pending state and establish the outcome from Stripe before any manual clearing or replacement.

A key derived from the attempt cannot do this, because the next attempt is a different attempt. An earlier version used the tenant, the amount and the cooldown window, which cancelled out exactly: any retry the cooldown permits is by definition in a later window, so it got a different key every time.

What counts as a failure

Saved-card selection has a persisted timestamp and PaymentIntent identity. Older or duplicate payment events cannot replace a newer card or its Stripe customer default, and older events do not change the replacement card’s failure state. Explicit card edits establish a new timestamp. When two payment creations have the same second-resolution timestamp, the existing selection is preserved; use an explicit card edit to resolve that ambiguity. Migration 9 backfills existing saved-card timestamps from account update times.

Payments arriving after close_account/1 still credit actual received money, but cannot restore the card or clear the closure reason.

Not everything that goes wrong is the card’s fault, and treating it as such is how a few minutes of Stripe being unwell disables auto top-up for every customer with a low balance.

What came back Verdict What happens
a decline (card_error, 402) definitive: the card said no counted; the key is cleared
a timeout, a dropped connection indeterminate logged, not counted; the key stays
a 5xx, a 429 indeterminate logged, not counted; the key stays
status: "processing" not a failure skipped; the key stays

That last row is ACH, SEPA and BECS: the money is on its way and the succeeded webhook will grant it. Treating it as a failure starts a second debit for the same top-up as soon as the cooldown lapses.

After auto_top_up_max_failures (default 3) definitive failures, auto top-up switches itself off and the owner is told. Only re_enable_auto_top_up/1 turns it back on:

{:ok, _account} = AuroraMeter.Pro.Credits.re_enable_auto_top_up(org)

Nudging the threshold slider does not re-arm a stopped card, and neither does a successful manual top-up. Both used to, which meant a customer who had just been refunded could silently re-arm their own card.

4. Refunds and chargebacks

You refund $25 in the Stripe dashboard. charge.refunded arrives, and Pro takes the credit back out of the ledger with Credits.reverse/4, never refused for want of balance, because the money has already left Stripe and a negative balance is the honest record of a debt.

Auto top-up is switched off at the same time, before the reversal is written, so the balance dropping cannot fire the low-balance hook and immediately charge the card you are refunding.

Subscribe to refund.created, refund.updated, and refund.failed as well. Refunds can fail after initially succeeding, or be cancelled. Pro retrieves the charge’s current amount_refunded under the payment/account locks and restores any excess reversal as an adjustment. It leaves the saved card and recharge settings alone. A duplicate failure restores nothing twice, and an old charge.refunded snapshot retrieves the current total instead of debiting again. If Stripe cannot be reached, the handler returns an error for retry. Custom credit Stripe clients must implement retrieve_charge/1 and list_charge_disputes/1 (all pages). Refunds and dispute events reconcile one combined target: the grant capped against current refunds plus unresolved/lost disputes. This prevents a refund delivered before a dispute-win event from being overlooked when the credit is restored. Won disputes and inquiries do not contribute to that target; any recorded dispute removes the saved card.

A charge that had tax added on top (credits_tax: :exclusive) charged more than it granted, so what is taken back is scaled to the grant: refunding $13.75 of a $27.50 charge that granted $25.00 takes $12.50 of credit.

Disputes

Event What it means What Pro does
charge.dispute.funds_withdrawn a chargeback took the money reverse the credit
charge.dispute.funds_reinstated you won put it back

The current charge’s amount_refunded is a net running total. Each dispute’s amount belongs to that dispute alone. Pro sums every unresolved/lost dispute returned by Stripe, adds the current refunded amount, and caps the result at the original payment grant. It compares this target with ledger reversals minus prior restoration adjustments and writes only the difference.

Two separate $10 disputes therefore contribute $20, while a repeated event does not change the target. A won dispute contributes zero, but any remaining refund or lost dispute still reduces spendable credit. Replaying old events retrieves the same current provider state. Historical direct calls without a charge ID retain the legacy per-event behavior; deliver complete Stripe events to use current-state reconciliation.

A reversal that arrives before its own grant

Radar’s early-fraud auto-refunds land within minutes of the payment, and each event retries on its own schedule, so a refund really can overtake the payment_intent.succeeded it reverses.

Answering :ignore there returns 200, Stripe marks the event delivered, and nothing ever notices: the money left and the credit stayed spendable. So a charge that carries your tenant metadata and has no grant yet is refused with a non-2xx, which is Stripe’s cue to redeliver. For an object without tenant metadata, the current handler retries only while it is less than one hour old, then ignores it if no grant identifies it. Monitor failed/discarded deliveries and reconcile payments whose grants were delayed beyond that window.

5. Being told what happened

config :aurora_meter_pro, credits_handler: &Parsely.Billing.credit_event/1

defmodule Parsely.Billing do
  def credit_event({:granted, txn}) do
    Parsely.Mailer.receipt(txn.tenant_key, txn.amount)
  end

  def credit_event({:low_balance, %{tenant_key: key, available: available}}) do
    Parsely.Mailer.low_balance(key, available)
  end

  def credit_event({:auto_top_up_failed, key, reason, attempt}) do
    Logger.warning("auto top-up #{attempt} failed for #{key}: #{inspect(reason)}")
  end

  def credit_event({:auto_top_up_disabled, key, reason}) do
    Parsely.Mailer.auto_top_up_off(key, reason)
  end

  def credit_event(_other), do: :ok
end

Keep the catch-all clause. New event kinds are added in minor versions and an unmatched one should not crash a webhook.

6. Expiring promotional credit

{"*/30 * * * *", AuroraMeter.Oban.CreditExpiry}

That worker is in the free core, not in Pro. AuroraMeter.Pro.Credits.Expirer is the deprecated name for the same work and still runs; move the crontab entry, and do not register both, which AuroraMeter.Oban.validate!/1 refuses. Either way it runs the core’s expire_due/1. Two behaviours worth repeating here: expiry will not take back money a hold has reserved, and a grant expires only its own remainder, so with two promotional grants live the first to expire cannot reclaim what the second put in.

7. Closing an account

AuroraMeter.Pro.Credits.close_account(org)

Stops auto top-up and detaches the saved card. It does not refund the remaining balance. If your policy refunds a paid top-up, issue the actual Stripe refund and let its webhook reconcile the ledger. Do not also write an unrelated manual reversal for the same refund: that would debit the customer twice. Promotional-credit adjustments and refunds spanning several payments require a separately reconciled ledger policy.

Where next

  • Usage reporting if you also sell a metered subscription
  • Operations for the dashboard, the audit log, the reconciler, the health check to run all of it from, the guarded recovery operations and what a Stripe or database outage does to a payment in flight
  • The ledger side of all this is the core’s Prepaid credits