Account and billing
Plans and billing
The organization is the billing entity: each one has its own plan. The catalog lives in one
place, packages/config/src/plans.ts (@ahena/config/plans), and Core, the API, the dashboard
and the pricing page (ahena.io/pricing) all read it.
| Plan | Price | Projects | Team | Actions / month |
|---|---|---|---|---|
| Free | $0 | 1 | 1 | 1,000 |
| Starter | $19/mo | 5 | 1 | 10,000 |
| Pro | $49/mo | 25 | 5 | 50,000 |
| Business | $149/mo | Unlimited | 20 | 250,000 |
| Enterprise | Custom | Unlimited | Unlimited | Custom (arranged directly) |
Limits
- Projects and members are firm. Creating a project or adding a member beyond the plan is
refused with
limit_reached(HTTP 402,details.upgrade: true); the dashboard shows the message with a link to Billing. The count runs under an advisory lock, so concurrent requests can't both slip under the limit. - One Free organization per owner when it is created or when an unfinished checkout falls back to Free, so limits can't be multiplied with extra organizations. It isn't checked when a cancelled subscription returns an organization to Free, so an owner can end up with more than one.
- Actions are soft. An action is one provider operation Ahena performs: validate/verify,
discover, inspect, state, Doctor (per provider), plan, apply (once, including its post-write
verification) and disconnect. Each
executionContextinconnections.tscounts one, per organization per UTC month (organization_usage). Reads of Ahena's own data aren't actions. Going over never blocks anything: Billing shows a warning, and the audit log recordsbilling.actions_warning(80%) andbilling.actions_over(100%) once each per month. - Downgrades never delete. Projects and members over the new limits keep working; nothing new can be added until the organization is within them.
Lifecycle
| Organization state | plan |
billing_status |
|---|---|---|
| Created on Free | free | active |
| Created on a paid plan, checkout not finished | the chosen plan | checkout (no projects or members can be added) |
| Stripe confirmed payment | from the subscription's price lookup key | active |
Payment failing (Stripe past_due or unpaid) |
unchanged, paid limits kept | past_due |
Subscription ended (Stripe canceled or incomplete_expired) |
free | active |
Whether failed payments ever end the subscription depends on the Stripe account's settings
(Billing → Subscriptions and emails → "if all retries for a payment fail"): "cancel the
subscription" moves the organization to Free; "mark as unpaid" or "leave past due" keeps it on
past_due with the paid plan's limits indefinitely. A Stripe paused subscription is treated as
active.
- Checkout:
POST /v1/orgs/:org/billing/checkout {plan}(owners,billing.manage). Without a subscription it returns a Stripe Checkout URL; with one it switches the subscription's price (prorated: the higher plan applies immediately and the difference is charged on the next invoice) and returnsurl: null. Prices are found by lookup key (ahena_<plan>_monthly). Session creation sends anIdempotency-Keymade of the organization, the plan and a 10-minute window, so double clicks or a second tab within that window get the same session. Sessions created further apart, or for different plans, are separate and Ahena doesn't expire the earlier ones, so two can still both be paid (see duplicate subscriptions below). When the organization already has a Stripe customer, the session uses it; otherwise Stripe creates one from the owner's email. - Webhook:
POST /v1/billing/stripe/webhook, authenticated only by theStripe-Signatureheader (HMAC-SHA256 over the raw body, 5-minute tolerance). The subscription is always read back from Stripe, so a replayed or out-of-order event for the organization's own subscription can't set a stale plan. Each event id is claimed inbilling_eventsbefore it is processed, so a redelivery, including one that arrives while the first is still running, is answered as a duplicate and not processed again. If processing fails (for example Stripe can't be read), the claim is released and the request fails, so Stripe's retry processes it. Plan changes are audited asbilling.plan_changedby the system. - Duplicate subscriptions: an organization has at most one subscription in Ahena. If a second
subscription becomes active while the organization's current one is still live (two Checkout
sessions both paid, or an old session paid after an in-place switch), it never replaces the
current subscription or customer. Ahena audits it once as
billing.duplicate_subscription(resultfailure; metadata holds only the two subscription ids and the Stripe event id) and leaves it alone: Ahena doesn't cancel or refund it. The duplicate keeps charging until support or the owner cancels and refunds it in the Stripe Dashboard (the customer portal only shows the organization's own customer). If the current subscription has already ended in Stripe, the new one takes over normally. - Portal:
POST /v1/orgs/:org/billing/portalopens Stripe's customer portal (payment method, invoices, cancellation). - Free fallback:
POST /v1/orgs/:org/billing/freeturns an unfinished checkout into Free (subject to the one-Free-organization rule). A paying organization cancels in the portal.