Refunds and payments management

You need: payments composed. This page is about reading state rather than creating it.

Nine statuses, answering two questions independently

Entitlements is what a status resolves to, entitlements across mobile and web is why a lapse on one rail does not always end access, and pithy payments reconcile is what catches a change the webhook missed.

Every rail’s vocabulary maps into nine normalized statuses. Each answers does it grant access and did its money ever arrive — and those are not the same question.

StatusGrantsMoney arrivedWhat it means
activeYesYesPaid and current
in_graceBy policyNoA failed renewal, still inside the retry window
on_holdNoNoA payment outstanding — retries exhausted, or a deferred payment still settling
canceledYesYesAuto-renew off, with the paid period still running
expiredNoYesA period you were paid for has ended
never_paidNoNoIt terminated before any money cleared
refundedNoNoMoney went back
revokedNoNoThe store took it back
pausedNoYesThe user suspended it

expired and never_paid are the pair to read twice

They look alike — both are over, neither grants — and they differ on the only question a balance cares about.

A bank debit that bounced. A subscription abandoned before its first charge. A deferred purchase canceled before payment. All three end with no charge, and reading them as merely expired credits a 100-coin pack for money that never arrived — with no clawback ever to follow, because there is nothing to reverse.

If you sell consumables, this is the distinction that costs you real money to get wrong.

Apple maps nothing to never_paid: StoreKit issues no transaction until the money moves.

canceled does not mean unentitled

Turning off auto-renew forfeits the next period, not the one already paid for.

A subject is entitled while some purchase granting that key is active, in_grace, or canceled with time left.

So a canceled subscriber keeps their access until the date they paid through — which is both correct and the thing a naive status === "active" check gets wrong.

in_grace is a status and a date, and the date is the half that is easy to get wrong

Grace only grants if the expiry covers the retry window — and the paid period’s end is not that date. By the time a store says grace, the period has already ended.

Apple reports the window separately from the transaction, so the rail carries the later of the two. Reading the transaction alone would record a grace period and revoke the subscriber in the same commit, which is the exact opposite of what grace is for.

Stripe and Play each report one expiry that already covers it.

Whether grace grants at all is your config key, defaulting to yes — because that is the point of grace: a failed card should not lock somebody out mid-month while the store is still trying.

paused is a status and a date too

A paused purchase carries when it resumes — the instant the store said it comes back, never one computed here.

Null means the store put no end on the pause. So paused until the 1st and paused indefinitely stay different sentences, and a row that is not paused can never carry the field at all — a database constraint enforces that.

What your UI should do in each

StateShow
activeNothing about billing
in_graceNothing about billing. Nagging somebody whose card is being retried is how you lose a customer who was never leaving
canceled, with time leftYour Pro runs until the 14th. Offer to resume
expiredYour Pro ended on the 4th, and the way back
refunded, revokedThe paywall. No apology, no explanation you cannot stand behind
on_holdWe could not take payment. A link to update the card
pausedPaused until the 1st, or paused — never invent the date

Lapsed rows come back with their flag false rather than filtered out, which is what makes those sentences possible.

A refund can arrive with nothing before it

On the merchant-of-record rails especially: a chargeback, a support decision, a tax correction, all issued without your side initiating anything.

The projection handles it as a state like any other, because it projects a state rather than applying a diff. But a support workflow that assumes a refund follows a request will not hold.

Out-of-order events, and why nothing here is last-write-wins

Providers do not guarantee delivery order.

An expired notification can arrive after the renewed that superseded it — and last-write-wins would then silently revoke a paying subscriber. That defect produces no error anywhere, and the subscriber reports it rather than your monitoring.

sequenceDiagram
    participant P as Provider
    participant W as Your Worker
    participant DB as Your D1
    P->>W: renewed, event time 10:00
    W->>DB: Row moves to 10:00
    P->>W: expired, event time 09:00
    W->>W: Older than the row it would update
    W--xDB: Ignored entirely
    Note over W,DB: A predicate in the database, not only a pre-read —<br/>two concurrent writers cannot order themselves

So the projection is monotonic on the provider’s own event time: an event no newer than the row it would update is ignored entirely. It is a database predicate as well as a pre-read, because two concurrent writers cannot order themselves correctly on their own.

Repairing one person’s state

pithy payments reconcile --env prod --subject user:usr_a1b2c3

The same steps the nightly cron runs, narrowed to one holder. This is the answer to my subscription isn’t showing up.

--dry-run reports the drift and writes nothing. --rail narrows to one store after that store’s webhooks were interrupted, rather than paying for the others.

A rising drift count means webhooks are not arriving — which is the signal the whole reconciliation exists to surface.

Comping and taking back

Two control-plane routes: grant an entitlement, and revoke one.

A comped key must be declared in your config’s manual entitlements first. A grant nothing declared is a grant nobody reviewed — and comped keys are also the ones that survive the catalog re-derivation, because a key the catalog never sold is a human’s decision.

Both are audited under the management client’s own actor kind.

ESC