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_centsin 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