Billing status
Tell Atune who pays, so the advice about paying customers turns on. Mappers for Stripe and Clerk Billing, with no new keys and no extra calls.
Only your app knows who pays. Until it tells Atune, every account is an assumed trial, and the advice that only makes sense for paying customers stays off:
| Advice | Needs |
|---|---|
| Churn risk | stage paying |
| Ready for upsell | stage paying and seats |
| Account gone quiet | stage paying |
| Champion cooling | stage paying |
| Feature adoption gap | stage paying |
| Account contracted | history of stage, plan, seats or mrr |
| Trial expiring | trialEndsAt |
| Payment failing | stage past_due |
Send the facts with the account you already pass to the SDK. Atune never calls
Stripe or Clerk for you, and only these fields are sent: stage, plan,
seats, mrr, currency, trialEndsAt and, for a cancellation,
cancelReason. Never card, invoice, address or Stripe ids.
Stripe: toAtuneSubscription()
Where your code already holds the customer's Stripe subscription (for example the copy your webhook handler saves in your database), map it:
import { createAtuneMiddleware, toAtuneSubscription } from "atune";
const atune = createAtuneMiddleware({
getAccount: async (request) => {
const session = await getYourSession(request);
if (!session) return null;
const subscription = await db.subscription.findUnique({ where: { orgId: session.orgId } });
return { accountId: session.orgId, userId: session.userId, subscription: toAtuneSubscription(subscription?.stripeObject) };
},
});Don't fetch the subscription from Stripe on every request. Read your own copy.
| Stripe | Atune |
|---|---|
status: "trialing" | stage trial, with trial_end as trialEndsAt |
status: "active" | stage paying |
status: "past_due" or "unpaid" | stage past_due |
status: "canceled" | stage canceled, with cancellation_details.feedback mapped to the reason list |
incomplete, incomplete_expired, paused, anything else | no stage: Atune keeps what it knows |
| quantity of per-seat prices only (see below) | seats |
unit_amount × quantity, normalised to a month | mrr and currency (paying and past-due only; discounts are not applied) |
first price's nickname, then lookup_key | plan (or pass { plan } yourself) |
Seats
Seats come only from prices you charge per seat. A flat-rate plan is bought
in quantity 1, and that 1 is not a seat count, so a flat-rate or metered price
sends no seats. With seats unknown, Atune never suggests an upsell.
A price counts as per-seat when:
- you list it:
toAtuneSubscription(sub, { seatPrices: ["price_123", "team_seat_monthly"] })(price id orlookup_key; then only these count), or - its Stripe metadata has
atune_seats: "true", or - it is a licensed (not metered) price bought in a quantity above 1.
If your app knows the seat count better, pass it: toAtuneSubscription(sub, { seats: org.seatLimit }).
That value wins. { seats: null } means unknown and sends no seats.
Clerk Billing: clerkAccount(auth, { billing: true })
With Clerk Billing, the active plan is already in the session your proxy
verifies (the pla claim). Ask clerkAccount to send it:
export default clerkMiddleware(async (auth, request) => {
return atune(request, NextResponse.next(), { account: clerkAccount(auth, { billing: true }) });
});A paid plan is reported as paying. The plans free, free_user and
free_org are reported as not paying (trial); pass
{ billing: { freePlans: ["hobby"] } } for your own free plan slugs.
Your own plan column
Pass the facts directly:
return { accountId: org.id, userId: user.id, subscription: { stage: org.paying ? "paying" : "trial", plan: org.plan, seats: org.seats } };The same fields work on track() and the
HTTP API.
npx atune init prints the right snippet for your app but never edits code
for this step.