A subscription is a small state machine, and every provider names the states
a little differently. This repo stores Dodo's word as provider_status and a
shared word as status, which is what access reads:
| Dodo status | What happened | Stored as | Access |
|---|---|---|---|
pending | checkout started, first payment not settled | incomplete | no |
active | paying, all normal | active | yes |
active, inside trial_period_days | free trial | trialing | yes |
past_due | a renewal failed, grace period open | past_due | yes |
on_hold | a renewal failed, no grace (or it ended) | unpaid | no |
paused | suspended on purpose | paused | no |
cancelled | ended | canceled | no |
failed | the first payment never went through | incomplete | no |
expired | the term ran out | expired | no |
An unknown word maps to incomplete, which grants nothing until someone maps
it.
Failed renewals: grace period or not
When a renewal charge fails, Dodo moves the subscription to on_hold, or to
past_due if you set a grace period (Settings -> Subscriptions ->
Subscription Grace Period). Dodo's own description of the two is the rule this
repo follows: past due keeps access until the window ends, on hold is loss of
access.
That puts the policy in one place, Dodo's dashboard:
- No grace period: the first declined renewal switches paid features off. Strict, and it costs you customers whose bank refused one charge.
- A grace period of a week or two: they keep working while they fix the card, and most do.
Turn on Payment Retries too (Settings -> Recovery). They are off by
default, so without them Dodo never charges a failed renewal again on its own:
the customer has to update their card in the portal, which charges what is due
and makes the subscription active.
The customer needs to hear about it. On payment.failed for a renewal whose
subscription is now past_due or unpaid, the adapter emits a
payment.failed billing event and the shared core mails a warning with a link
to /billing. A first payment declined at checkout gets no warning: the buyer
saw it on Dodo's page.
Trials
Dodo has no trial status. A trialing subscription is active with
trial_period_days above zero, counted from created_at. The adapter works
out the trial end and stores trialing until then. The trial length comes from
the catalogue (trialDays in src/lib/pricing.ts), sent with each checkout,
so a trial on the Dodo product itself is overridden.
Cancelling at the period end
A cancellation in Dodo's portal sets cancel_at_next_billing_date and leaves
the subscription active until then. That arrives as subscription.updated,
so subscribe to it. The row stores cancel_at_period_end = true, and the
billing page says "Ends on" the next billing date instead of "Renews on". Always
read that through scheduledEnd(subscription), never the flag alone.
Plan changes
subscription.plan_changed fires when the customer switches plans in the
portal. The subscription now has a different product_id, while the checkout
metadata still names the plan they first bought. The adapter names the plan
from the product only (BILLING_PRICE_* maps it back to the catalogue), so a
downgrade is never read as the old plan. A product no env var names is stored
with no plan: shown as paid, unlocking no plan, and logged.
Re-read on every event
Every subscription.* handler throws the payload away and calls
dodo.subscriptions.retrieve(id). Dodo retries a failed delivery for about a
day, so events arrive out of order: a subscription.active from before a
cancellation can land after it. Storing whatever Dodo says now makes the order
irrelevant, and makes a resend from the dashboard (or
bun run billing:reconcile) a safe way to repair anything.
Which events to subscribe
All of these, on both the test and the live endpoint:
subscription.active subscription.updated subscription.renewed
subscription.plan_changed subscription.past_due subscription.on_hold
subscription.paused subscription.unpaused subscription.cancelled
subscription.failed subscription.expired
plus payment.succeeded and payment.failed for receipts and warnings. The
list in code is HANDLED_EVENTS in src/lib/billing/dodo-events.ts. The
easy ones to miss are subscription.updated (scheduled cancellations) and
subscription.plan_changed (portal switches): without them the app keeps the
old truth.