A Rails-focused Ruby gem for SaaS billing. ActiveBilling manages billing cycles, tracks plan-based and usage-based consumption, closes cycles, generates invoices, and records payment. It runs embedded inside an existing Rails application (models, controllers, jobs, helpers) or standalone as a thin billing service that receives input from external apps over a JSON API.
Implementation status. This README documents both the shipped surface and the intended design. What's implemented today: the core models (
Plan,Billing,Usage,Event,Invoice,InvoiceItem,Charge), the polymorphic billable-entity model,billable_entityscoping, a read-only portal web UI, and the standalone JSON API (see Standalone usage) — all with override generators. Partial lifecycle helpers are shipped as guarded transitions (Billing#finalize!,Usage#close!,Invoice#issue!/#cancel!); still planned:BillingLineItemadjustments and the Charge state machine. Planned sections below are marked as such. See CHANGELOG.md for the authoritative status.
The central concept is a Billing. A Billing record represents one configured billing cycle for a billable entity (a customer, organization, tenant — anything in your app you want to bill). It carries:
- the cycle window (start, end, interval — monthly, weekly, or a custom interval)
- a snapshot of the Plan attached to the cycle (fixed recurring price + included allowances)
- references to the Usage records measured during the cycle
- additional line items, credits, and discounts added before finalization
When the cycle ends, the gem closes the underlying Usage, finalizes the Billing, generates the Invoice, and records or initiates the payment via Charge.
Billable Entity (Customer / Organization / Tenant)
│
│ has_many
▼
Billing (cycle config + plan snapshot + adjustments)
│
├── Usage (closed measurement of events in the cycle)
│ └── Event (individual billable action)
│
├── adjustments: extra line items, credits, discounts
│
▼ finalize
Invoice ── has_many ──► InvoiceItem
│
▼
Charge (payment state machine)
| Model | Responsibility | Mutable? |
|---|---|---|
Plan |
Catalog entry: recurring price, included allowances, metadata | Yes (catalog edits do not affect past Billings — they snapshot) |
Billing |
One configured billing cycle for a billable entity; aggregates Usages + plan + adjustments | Yes until finalized |
Usage |
Per-period measurement bucket for one billable entity | Yes while open; closed at cycle end then immutable |
Event |
A single billable action recorded against a Usage (API call, SMS, storage, etc.) | Append-only |
Invoice |
Document generated from a finalized Billing | State-machine; immutable items after issued |
InvoiceItem |
Line item on an invoice | Owned by Invoice |
Charge |
Payment record + state machine (created → processing → paid / failed / expired) | State-machine |
1. Configure cycle ── create Billing with cycle window + Plan snapshot
2. Record consumption ── append Events to the open Usage(s) for this entity
3. Close cycle ── Usage transitions to "closed" (no more events)
4. Adjust Billing ── add/edit line items, apply credits and discounts
5. Finalize Billing ── locks adjustments; generates Invoice + InvoiceItems
6. Issue Invoice ── invoice moves to "issued"; Charge is created
7. Collect payment ── Charge transitions through processing → paid/failed
ActiveBilling installs as a Rails engine inside your app. You drive billing from code, talking to ActiveBilling::Billing, ActiveBilling::Usage, etc. directly — same models, jobs, helpers as anything else in the app. On top of that you get a read-only portal (index/show) to follow those entities — the UI is for visibility, not management, which is why it ships no create/update/destroy screens. Management happens in your code.
Use this when billing is part of the same Rails monolith as the product.
The same gem can be mounted inside a thin Rails app to run as a separate billing service. Beyond code-level control, other apps in your ecosystem drive billing over the JSON API (see Standalone usage): a versioned, token-authenticated surface with almost full control of every model. It is not a raw CRUD passthrough — it runs the same domain logic and respects the business rules and model validations (soft deletes, one-usage-per-cycle, append-only events, the invoice state machine, and so on). The gem ships the engine, routes, controllers, and jbuilder serializers; the host app supplies the auth callable.
Use this when multiple products share one billing service, or when billing needs to run in its own deployable.
# config/routes.rb in a standalone billing service
Rails.application.routes.draw do
mount ActiveBilling::Engine => "/billing"
endBoth modes use the same models and the same lifecycle — standalone mode is the embedded engine + an HTTP surface.
Add to your Gemfile:
gem "active_billing"Then:
bundle install
rails active_billing:install:migrations
rails db:migrateThe migrations enable the pgcrypto and hstore PostgreSQL extensions automatically.
Requirements:
- Rails 6.0+ (developed/tested against Rails 8.1)
- PostgreSQL (uses
jsonb,hstore,gen_random_uuid()) - Ruby 2.7+ (developed/tested on Ruby 3.2)
config/initializers/active_billing.rb:
ActiveBilling.configure do |config|
# Default currency for new Billings (ISO code)
config.currency = :BRL
# Default cycle interval for new Billings (:monthly, :weekly, or a Duration)
config.default_cycle_interval = :monthly
# Default penalty / interest applied to overdue Charges (in basis points; 200 = 2%)
config.default_penalty = 200
config.default_interest = 100
# Method called on resources to resolve the billable entity
config.billing_entity_method = :billing_entity
# Default polymorphic type of billable entities (e.g. "Customer", "Store").
config.billable_entity_class = nil
# Standalone mode: master switch for the mountable JSON API. While false, every
# API route responds 404.
config.api_enabled = false
# Callable invoked on every API request: ->(api_key, request) { scope }.
# Return a truthy scope object to authorize the request; return a falsy value to
# reject it with 401. When api_authorizer is nil, the API rejects with 403.
config.api_authorizer = ->(api_key, _request) { ApiToken.find_by(token: api_key) }
# Base controller for the portal pages. Point it at your own controller so the
# portal inherits your authentication, layout and CSRF configuration.
config.parent_controller = "ActionController::Base"
# Optional invoice description template (String or callable receiving the invoice).
# Supports %{month} and %{uuids}.
config.invoice_description = nil
# Standalone mode (planned): enable the mountable API and set auth callable.
# The config keys exist today; the API controllers/serializers are not yet shipped.
config.api_enabled = false
config.api_authorizer = ->(request) { ApiToken.find_by(token: request.headers["X-Api-Key"]) }
endclass Customer < ApplicationRecord
has_many :billings,
as: :billable_entity,
class_name: "ActiveBilling::Billing",
dependent: :destroy
has_many :usages,
as: :billable_entity,
class_name: "ActiveBilling::Usage",
dependent: :destroy
has_many :invoices,
as: :resource,
class_name: "ActiveBilling::Invoice",
dependent: :destroy
def billing_legal_name = company_name.presence || name
def billing_tax_document = tax_id
endThe shipped Plan model uses name, price_in_cents (a money-typed attribute — see Currency and money), interval (monthly/yearly), allowances (jsonb), and active:
plan = ActiveBilling::Plan.create!(
name: "Pro",
price_in_cents: 9_900, # cents; or ActiveBilling::Money.from_amount(99.00)
interval: "monthly",
allowances: { api_call: 10_000, sms_sent: 200 },
metadata: { tier: "pro" }
)
plan.price_in_cents # => #<ActiveBilling::Money BRL 99.00>
plan.price_in_cents.to_d # => 0.99e2 (BigDecimal, exact)
plan.price_in_cents.cents # => 9900Billing is the connector between a billable entity and its usages/invoices/charges. It belongs to a polymorphic billable_entity (the payer — a chain, a store, a customer…), optionally references a Plan (snapshotted onto the billing while it is open), and has a state of open/finalized.
billing = customer.billings.create!(
plan: plan, # snapshotted onto the billing
period_start: Date.current.beginning_of_month,
period_end: Date.current.end_of_month
)
ActiveBilling::Billing.current_for(customer) # latest open billing for an entityBecause a Billing can aggregate Usage records from several resources, multiple stores under one chain can either be billed individually (one Billing each) or unified into a single Billing → Invoice → Charge.
Planned: automatically opening a
Usagewhen a Billing is created, and theclose!/finalize!lifecycle helpers shown below, are not yet implemented. Create and associateUsagerecords directly for now.
billing.usage.events.create!(
kind: "api_call",
resource: api_request,
metadata: { endpoint: "/v1/users" },
chargeable: true
)Planned. Steps 5–7 below (
close!, the adjustment helpers, andfinalize!) describe the target lifecycle and are not yet implemented. Today, build Invoices/Charges from Usages directly.
billing.close! # closes the Usage; computes usage-derived line items;
# adds plan recurring charge from the snapshotBetween close and finalize, the Billing is editable. Use this window for credits, discounts, manual line items, or to combine extra Usages.
billing.line_items.create!(key: "setup_fee", description: "One-time onboarding", quantity: 1, unit_price: 250.00)
billing.apply_credit!(amount: 50.00, reason: "loyalty")
billing.apply_discount!(percent: 10, reason: "promo")
billing.add_usage!(other_closed_usage) # combine multiple Usagesinvoice = billing.finalize! # locks the Billing, generates Invoice + items
invoice.issue! # creates a Charge in "created" state
# In v1, Charge state transitions are driven by your application or a
# payment-provider adapter you write. See "Payments" below.
charge = invoice.charge
charge.mark_processing!
charge.mark_paid!(paid_at: Time.current)The engine ships a read-only portal that a host app gets for free once the engine is mounted. Every page is scoped to the billable entity returned by config.portal_billable_entity, a callable that receives the controller; authentication itself is the host app's job — wrap the routes with your own auth and set config.parent_controller so the portal controllers inherit current_user and friends:
config.parent_controller = "ApplicationController"
config.portal_billable_entity = ->(controller) { controller.current_user&.organization }The portal never reads the entity from the query string, so users cannot browse another entity's data. Resolve it from state every request carries (session, current_user, subdomain, a route segment of the mount point).
Mounted at the engine's path (e.g. /billing):
| Route | Action | Purpose |
|---|---|---|
GET /invoices |
index | Invoices for the billable entity (via their Billing) |
GET /invoices/:id |
show | One invoice + its items |
GET /usages |
index | Usages measured for the billable entity |
GET /usages/:id |
show | One usage (404 for another entity's) |
GET /charges |
index | Charges for the billable entity (via invoice → billing) |
GET /charges/:id |
show | One charge (404 for another entity's) |
GET /plan |
show | The current plan for the billable entity (from its open Billing) |
Every portal action returns 403 Forbidden when config.portal_billable_entity is not set, 400 Bad Request when it returns nil (e.g. nobody is signed in), and the show actions return 404 for a record that belongs to another entity. All user-facing strings go through I18n.t with English defaults in config/locales/active_billing.en.yml.
Note: the portal is read-only (
index/show). Create/update/destroy and an admin UI are not part of this surface.
The shipped views/controllers are used as-is by default. To customize them, generate local copies into your app — Rails resolves your app's app/views and app/controllers ahead of the engine's:
# Copy the portal views into app/views/active_billing/** to override the defaults:
bin/rails generate active_billing:views
# Copy the portal controllers into app/controllers/active_billing/**:
bin/rails generate active_billing:controllers
# Write a config/initializers/active_billing.rb and print setup steps:
bin/rails generate active_billing:installThe JSON API is overridable the same way. Eject a single resource (pre-filled with the default behavior) and customize just that one without forking the gem — Rails resolves your app's copy ahead of the engine's:
# One API controller (e.g. charges) into app/controllers/active_billing/api/v1/:
bin/rails generate active_billing:api_controller charges
# One resource's jbuilder views into app/views/active_billing/api/v1/<resource>/:
bin/rails generate active_billing:api_views invoices
# The API base controller (for cross-cutting overrides such as custom auth):
bin/rails generate active_billing:api_baseEnable the API with config.api_enabled = true and a config.api_authorizer (see
Configuration). Every request carries an X-Api-Key header; endpoints
are prefixed by the mount path (/billing below) and versioned under /api/v1.
POST /billing/api/v1/plans
Content-Type: application/json
X-Api-Key: <token>
{ "plan": { "name": "Pro", "price_in_cents": 9900, "interval": "monthly" } }The API exposes every model with full CRUD, guarded by the domain rules. Custom transitions are modeled as REST noun sub-resources.
| Resource | Endpoints | Domain guards |
|---|---|---|
| Plans | GET/POST /plans, GET/PATCH/DELETE /plans/:id |
DELETE hard-deletes only if the plan was never used; otherwise it is deactivated (active: false). |
| Billings | GET/POST /billings, GET/PATCH/DELETE /billings/:id, PUT /billings/:id/plan, POST /billings/:id/finalization |
DELETE is a soft delete. plan/finalization only while open (else 409). |
| Usages | GET/POST /usages, GET/PATCH/DELETE /usages/:id, POST /usages/:id/closure |
One usage per billing cycle (409 on duplicate). DELETE only when empty. A closed usage is immutable. |
| Events | GET/POST /events, GET/DELETE /events/:id |
Append-only: no update. Cannot be added to a closed usage. |
| Invoices | GET/POST /invoices, GET/PATCH/DELETE /invoices/:id, POST /invoices/:id/issuance, POST /invoices/:id/cancellation |
DELETE is a soft delete. PATCH/items only while issuable; cancellation only while cancellable?. |
| Invoice items | GET/POST /invoices/:id/items, GET/PATCH/DELETE /invoices/:id/items/:id |
Writes rejected once the invoice is issued (409). |
| Charges | GET/POST /charges, GET/PATCH/DELETE /charges/:id, POST /charges/:id/payment |
DELETE is a soft delete. payment returns 501 until the Charge state machine ships. |
Index actions accept optional billable_entity_type / billable_entity_id query params
to scope results to one entity.
Errors use a consistent envelope:
{ "error": { "code": "validation_failed", "message": "...", "details": { "field": ["..."] } } }Status codes: 401 (bad/missing key), 403 (no api_authorizer configured), 404
(API disabled or record not found), 409 (illegal transition / conflict), 422
(validation), 501 (roadmap endpoint).
Embedded callers get the same behavior by calling the model methods directly; standalone callers get it over HTTP. Both paths exercise identical domain logic.
Status.
Chargecurrently ships as a payment record (polymorphicpayer/resource, optionalinvoice, default penalty/interest,for_billable_entityscope). The full state machine (created → processing → paid / failed / expired) and theafter_paid-style callbacks below are planned.
ActiveBilling aims to ship the Charge state machine (created → processing → paid / failed / expired) and an extension surface for payment providers, but it does not ship a Stripe/Pagar.me/etc. adapter. Wire your own provider via callbacks:
ActiveBilling::Charge.after_create :send_to_gateway
ActiveBilling::Charge.after_paid :reconcile_with_ledgerA pluggable provider interface (with a Stripe reference implementation) is on the roadmap — see CHANGELOG.md for status.
All monetary values are stored as integer cents. The *_in_cents columns use a custom ActiveRecord attribute type (ActiveBilling::Type::Money, registered as :active_billing_money) that casts them to an immutable ActiveBilling::Money value object:
attribute :amount_in_cents, :active_billing_money # declared on the model
invoice.amount_in_cents = 9_999 # assign cents…
invoice.amount_in_cents = ActiveBilling::Money.from_amount(99.99) # …or from major units
invoice.amount_in_cents # => #<ActiveBilling::Money BRL 99.99>
invoice.amount_in_cents.cents # => 9999
invoice.amount_in_cents.to_d # => 0.9999e2 (BigDecimal — exact, not Float)
invoice.amount_in_cents.as_json # => 9999 (stable integer serialization)Money is Comparable, supports +/-, and serializes to integer cents — so validations (comparison: { greater_than: 0 }), JSON, and database round-trips stay exact. This replaced the earlier CurrencyAttribute concern; using a real type means casting/serialization is handled by Rails' attributes API instead of generated accessor methods.
Default currency is configured globally (ActiveBilling.configuration.currency) and used when building Money values.
The NfeDescription concern produces NFe-compatible invoice descriptions when the host application issues Brazilian fiscal documents. It is opt-in and not required for general use.
Override the gem's models in your app to add domain-specific behavior:
class Plan < ActiveBilling::Plan
# custom plan logic
end
class Billing < ActiveBilling::Billing
def event_price_for(kind)
# your pricing rules
end
endCommon extension points:
Usage#event_price_for(kind)— per-event pricing (override in your app; defaults to0.0)Usage#calculate_event_cost(event)— per-event cost calculation (override; defaults to0.0)Usage#calculate_total_cost— total cost across eventsCharge#billing_entity— return the entity that receives payments (must be overridden)- Custom
Event.kindsentries — your own billable verbs Billing#close!/Billing#finalize!— planned lifecycle hooks
The gem creates the following tables (all prefixed active_billing_):
active_billing_plans— plan catalogactive_billing_billings— billing cyclesactive_billing_usages— per-cycle measurement bucketsactive_billing_events— individual billable eventsactive_billing_invoices— invoice documentsactive_billing_invoice_items— invoice line itemsactive_billing_charges— payment records
See db/migrate/ for the authoritative schema and indexes.
- ARCHITECTURE.md — data model, flows, design patterns
- STRUCTURE.md — file/directory layout
- USAGE_EXAMPLES.md — practical recipes (plans, adjustments, standalone API)
- CHANGELOG.md — version history
- CLAUDE.md — code style and conventions for contributors
Bug reports and pull requests are welcome on GitHub. Before submitting, run:
bundle exec rspec
bundle exec rubocopFollow the conventions in CLAUDE.md — they are enforced by review.
MIT. See MIT-LICENSE.