Billing

Licenses, plans, and limits, with no payment provider. Builds on the admin panel. A license gives a user a plan from a source (an admin grant, a payment provider, a marketplace like AppSumo) for a period of time. The app only ever asks one question, "what is this user allowed to do?", and the answer is the same however they paid.

v1.8.3
Level: Advanced
16
1371
1

The Billing context

The app's side of the API is three functions:

Limits are counted : the caller passes in how many projects or seats exist now, and billing only knows the cap. Nothing is stored, the number is always right because it is the data, and deleting a project frees a slot automatically. Metered usage, like API calls per month, needs a running counter and is left for a separate feature.

The admin's side is grant_license/3 , update_license/2 , and cancel_license/1 . The last two return {:error, :not_editable} for provider licenses.

The provider's side is one function, sync_license/4 . A provider calls it with its own id for the record, every time that record changes. It upserts on {source, source_ref} with returning: true , so replays and races are harmless:

Every change broadcasts {:licenses_changed, user_id} on PubSub, so open pages update when an admin grants or cancels a license.

Merging licenses

merge/1 is the one place that decides what several licenses add up to. The default is best wins : the active license with the highest-ranked plan grants that plan, with its own overrides applied. On a tie, the license that lasts longer wins. No license means Free.

Best wins is what most SaaS products do for base plans, and it is forgiving. Two Pro licenses still give you Pro. Canceling one of two licenses never drops you below what the other one grants. It never stacks the same plan twice, though, which is why the duplicate warning exists: if support creates a second subscription by mistake, the user's access is right but they may be paying twice.

The moduledoc describes how to add add-ons , like seat packs or stacked lifetime deals, whose limits sum on top of the base plan. The model already supports them, since an add-on is just a plan and a license. It takes a flag on Plan , a split in merge/1 , and leaving add-ons out of the duplicate check.

has_feature?/2 , limit/2 , and check_limit/3 read the merged result. Unknown feature or limit names raise, so a typo fails loudly instead of quietly denying everyone.

Licenses

Two changesets for two kinds of writer:

Overrides are normalized on the way in and stored with string keys, as they come back from the database. A number is a cap, nil is unlimited, and the admin form's blank field means "use the plan's limit".

display_status/2 derives :scheduled , :active , :expired , or :canceled from the stored status and the dates. editable?/1 is true only for manual licenses: provider licenses mirror the provider, so an admin edit would be undone by the next sync.

users_with_multiple_active_query/1 finds users holding more than one active license, for the admin warning.

License derives Flop.Schema with an email join field, so the admin licenses list can search by user email.

Plans

A plain struct: a key, a name, a rank used to pick the best of several licenses, public? for whether customers are offered it, a cap for every limit ( nil is unlimited), and a list of features.

The catalog. It ships with Free, Pro, Business, and a non-public Enterprise plan, with example projects and members limits and a few features. Replace them with your own.

Two rules keep plans in code safe:

The module checks itself at compile time: every plan must define every limit and only known features, or the app won't build. A typo in the catalog fails CI instead of locking customers out.

Admin: licenses

Flop-powered listing and lookup with the user and granting admin preloaded, following the admin panel's query module pattern.

Every license, filterable by plan, source, and user email. A warning at the top lists users with more than one active license, linking to each one.

A license's details: status, plan, source and reference, dates, overrides, the admin who granted it, and the note. Manual licenses can be edited or canceled. Provider licenses say which provider they mirror and offer neither.

Grant and edit share one form. Dates are days on the admin calendar (from the admin panel's configured timezone), and a license starts or ends at the beginning of that day. Each limit override is a text field with the plan's own limit as its placeholder, which updates as you change the plan.

Components shared by the billing pages: the status badge, the entitlements summary, and the multiple-licenses warning.

Admin: users and navigation

The user page gets a Billing section: what their licenses add up to, every license they've held, a duplicate warning when they hold more than one, and a "Grant license" button.

A "Licenses" item in the admin sidebar.

The user's billing page

/billing shows the user's plan, its limits and features, and when it ends, followed by the public plans with the current one marked. It subscribes to license changes, so an admin's grant shows up without a refresh. With no payment provider, the way to change plans is "contact us". A provider feature replaces that with checkout.

A "Billing" link in the user nav.

Routing

/billing is in the :require_authenticated_user live session, since it always shows the signed-in user's plan. The license pages are in :admin_authenticated , next to the other admin pages. /admin/licenses/new comes before /admin/licenses/:id so "new" isn't read as an id.

Database

One licenses table:

Tests

license_fixture/2 grants a manual license that started a minute ago, through the real grant_license/3 , so fixtures go through the same validations as admins do.

Covers the merge rules (best wins, ties, overrides, ignoring canceled, expired, and scheduled licenses), feature and limit checks, validation, idempotent provider syncs, and the duplicate-license query.