vinta-django-billing
Subscriptions, plan limits, entitlements, metered usage and dunning for multi-organization Django applications.
Built on vinta-django-orgs: every table here is scoped to that library's swappable organization model, and the engine reads nothing from it beyond the swappable model reference and the tenancy mixin.
Status: alpha. The API will change before 1.0.
What it is
A billing engine that does not know what it is billing for.
It knows how to resolve an organization's ceiling for a resource, pool usage across a reseller subtree, refuse a create that would exceed a limit, meter post-paid usage, run a dunning ladder over a failed charge, and close a billing period. It does not know what a "seat" is, or a "calendar", or an "API token" — those are the host application's, and they come in through registries.
| Ships here | Stays in your project |
|---|---|
Subscription, BillingPlan, PlanLimit, PlanEntitlement, BillingProfile, Payment, Refund, MeteredOccurrence, … |
The models being limited |
| The limit / entitlement / pooling engine | Which resources exist, and how to count them |
| Stripe and MercadoPago adapters | Provider credentials |
| Dunning ladder and usage warnings | The notification transport |
| The billing-root protocol | Whether your organizations even have a hierarchy |
Install
pip install vinta-django-billing # or: uv add vinta-django-billing
pip install "vinta-django-billing[stripe]" # provider SDKs are extras
Extras: stripe, mercadopago, openapi.
The distribution is typed (PEP 561): it ships a py.typed marker from 0.5.0 on,
so mypy reads the annotations instead of treating the package as Any. If you
listed vinta_billing under ignore_missing_imports to silence it, drop that
entry — while it is there the annotations are still discarded, and a
from vinta_billing.models import * re-export out of an Any module re-exports
nothing.
INSTALLED_APPS = [
...,
"rest_framework",
"vinta_orgs.apps.OrganizationsConfig", # vinta-django-orgs
"vinta_billing.apps.BillingConfig",
]
MIDDLEWARE = [
...,
"django.contrib.auth.middleware.AuthenticationMiddleware",
# After `AuthenticationMiddleware`. `vinta-django-orgs` refuses an
# organization the caller holds no active membership in, and it needs
# `request.user` to do that; placed earlier the check silently does nothing.
# Its `vinta_orgs.W001` system check reports the wrong order.
"vinta_orgs.middleware.OrganizationMiddleware",
]
Register what you bill for
The closed TextChoices the engine was extracted with became two registries.
Register from your AppConfig.ready() — the only hook that runs after the app
registry is populated and before anything serves a request.
# myproject/billing_setup.py
from django.utils.translation import gettext_lazy as _
from vinta_billing.constants import LimitKind, LimitRemedy
from vinta_billing.counting import count_by_organization, merge_breakdowns
from vinta_billing.registry import entitlements, resources
from vinta_billing.services.entitlement_service import count_metered_occurrences
def count_seats(context):
"""Memberships plus still-open invitations, per organization."""
return merge_breakdowns(
count_by_organization(
Membership.objects.filter(organization_id__in=context.organization_ids)
),
count_by_organization(Invitation.objects.pending(context.organization_ids)),
)
resources.register(
"seats",
label=_("Seats"),
kind=LimitKind.PREPAID,
counter=count_seats,
remedy=LimitRemedy.UPGRADE_PLAN,
# The `usage_extra` keys `count_seats` reads. Optional; see below.
usage_extra_keys={"exclude_invitation_id"},
)
resources.register(
"events",
label=_("Events"),
kind=LimitKind.POSTPAID,
counter=count_metered_occurrences,
# Reads nothing per call, and says so.
usage_extra_keys=frozenset(),
)
entitlements.register("white_label", label=_("White-label branding"))
A counter takes a UsageContext and returns
{organization_id: count}. Organizations at zero must be absent from the
mapping rather than present with a zero — GROUP BY never emits a row for them,
and count_by_organization preserves that.
Read unscoped. On a model using
SingleOrganizationModelMixin, count throughModel.objects.unscoped()orModel.original_manager, never the scoped default manager. Usage pools across a whole billing subtree, so a counter is asked about several organizations at once and must not be narrowed to whichever one is bound to the current context —organization_id__inis the tenant boundary here, and it is the counter's own filter.In a background sweep nothing is bound at all, and a scoped read then depends on
vinta-django-orgs'STRICT_ORGANIZATION_FILTER:True(its default since 0.3) raisesOrganizationNotFoundErrorand the sweep dies;Falsereports zero for everybody and every ceiling silently reads as empty. Neither is a counter you want.
Registering a resource never asks for a migration: the fields storing resource
keys take their choices by callable reference, so the migration state does not
change when the registry does.
Per-call data, and declaring it
A counter that needs something the call site knows — "the invitation currently
being accepted, which must not be double-counted" — reads it out of
UsageContext.extra, which the caller fills through usage_extra. The engine
never reads the values.
It does check the keys, but only if you asked it to. usage_extra_keys names
what a counter reads; omit it and nothing is checked, which is how every
registration behaved before 0.4.0. Declare it — on every resource, including
the ones that read nothing, as frozenset() — and a key aimed at the wrong
resource raises InapplicableUsageExtraError instead of being ignored. That is
worth doing because the mistake is otherwise invisible: a counter that does not
read a key ignores it, so the caller gets a count computed as though they had
passed nothing and no part of the answer says so.
Enforce a limit
from vinta_billing.services.container import get_entitlement_service
result = get_entitlement_service().check_limit(organization, "seats", delta=1, lock=True)
if not result.allowed:
raise OverLimitError.build(result)
lock=True takes SELECT ... FOR UPDATE on the billing root's subscription row
before counting, so two racing creates for the last unit of capacity serialize
and exactly one sees room. It requires an open transaction.
Three rules the engine holds to, and which are easy to break by accident:
- NULL is unlimited, never zero. A missing limit row means the same. Both fail open — a data gap must never lock a customer out of something they could do yesterday.
- Usage pools at the billing root. A child organization's usage counts against its root's ceiling, together with the rest of the subtree.
- Counting and checking are inseparable under concurrency. See
lock.
When the per-call data your counter needs is itself a query, pass
usage_extra_resolver rather than usage_extra:
result = get_entitlement_service().check_limit(
organization,
"seats",
usage_extra_resolver=lambda: {"exclude_invitation_id": find_the_invitation()},
)
It is called at most once, and only once the ceiling is known to be finite — so
an organization on an unlimited plan, which skips counting entirely, never pays
for the query. Pass one or the other, never both. check_postpaid_allowance's
delta_resolver is the same idea for a delta that costs a query to work out.
Configure the seams
Everything the engine cannot know, in one settings dict. Every key has a default that works, so a flat single-tenant project configures nothing.
VINTA_BILLING = {
# Who pays for whom. Default: every organization is its own billing root.
"HIERARCHY": "myproject.billing.ResellerHierarchy",
# Who may see and change billing. Default: any member of the organization.
"BILLING_MANAGER_PREDICATE": "myproject.billing.is_billing_owner",
# Who hears about a failed charge or an approaching limit. Default: every
# member of the organization.
"BILLING_RECIPIENTS": "myproject.billing.owners_and_admins",
# Where dunning and warning messages go. Default: log and drop.
"NOTIFIER": "myproject.billing.Notifier",
# What the meter bills. Default: the single registered postpaid resource.
"OCCURRENCE_SOURCE": "myproject.billing.EventOccurrenceSource",
"METERED_RESOURCE_KEY": "events",
# How a sweep hands each per-subscription job over. Default: run it inline.
# The jobs the sweep hands over build their services through
# `SERVICE_CONTAINER`, same as the views.
"JOB_DISPATCHER": "myproject.billing.enqueue",
# How your DRF surface resolves the acting organization, and where the
# shipped views build their services. Defaults: this package's own mixin
# and its own container. See "Mounting the routes in a project that has its
# own tenancy and its own DI" below.
"VIEW_MIXIN": "myproject.api.TenantScopedViewMixin",
"SERVICE_CONTAINER": "myproject.di.container",
# Per-provider credentials. A provider absent here stays registered -- its
# inbound webhook route keeps resolving -- but every outbound call site
# refuses it rather than authenticating with an empty credential.
"PROVIDERS": {
"stripe": {
"API_KEY": env("STRIPE_SECRET_KEY"),
"WEBHOOK_SECRET": env("STRIPE_WEBHOOK_SECRET"),
"PUBLISHABLE_KEY": env("STRIPE_PUBLISHABLE_KEY"),
},
},
"DEFAULT_PROVIDER": "stripe",
# Absolute base the provider callback URLs are built against.
"SITE_DOMAIN": "api.example.com",
}
The full list of keys, each with the default it falls back to, is in
vinta_billing/conf.py. An unknown key raises rather than being
ignored, so a typo cannot silently leave you on a default.
Rendering errors
The services raise typed errors carrying a machine-readable code. Point DRF at
the shipped handler to render them, or call it from your own:
REST_FRAMEWORK = {
"EXCEPTION_HANDLER": "vinta_billing.exception_handling.billing_exception_handler",
}
Over-limit and declined-charge errors render as 402, subscription-state
conflicts as 409, and deployment faults as 503 — an unconfigured provider
(PaymentProviderNotConfiguredError), a missing default plan
(NoDefaultBillingPlanError), a plan with no limit row for a registered
resource (IncompleteBillingPlanError). See
vinta_billing/exception_handling.py for the table,
which is the contract: the shipped OpenAPI annotations are checked against it by
the suite, so what you generate a client from is what the handler returns.
The 503 family is the one worth a second's thought before you wire your own
frontend to it. Nothing the caller sent is wrong and the same request will fail
identically until an operator fixes the deployment, so a 4xx would be an
invitation to retry something that cannot work — and a 5xx is what your error
monitoring is already watching, which is the difference between noticing an
unconfigured provider in an hour and filing it under "clients sending bad
requests" for a week. Three annotations in 0.5.0 documented
PaymentProviderNotConfiguredError as a 409; they were wrong about what the
handler did, and 0.6.0 corrected them rather than the handler.
Who may manage billing
Both seams above default to every member: the most permissive answer that is still tenant-safe, so the shipped endpoints work before anything is wired.
If your project expresses roles as vinta-django-orgs organization permissions,
Subscription declares a codename to grant — vinta_billing.manage_billing —
and this package ships both halves of the question already written against it:
VINTA_BILLING = {
"BILLING_MANAGER_PREDICATE": "vinta_billing.permissions.member_holding_manage_billing",
"BILLING_RECIPIENTS": "vinta_billing.recipients.members_holding_manage_billing",
}
Nothing here grants the permission, so select these only once a group carries it. Until then the predicate 403s every billing endpoint and, worse, the recipient resolver returns nobody — which turns the dunning ladder into a suspension the payer was never warned about.
Both read the organization-scoped grant alone (vinta_orgs.authorization), never
user.has_perm: billing is routinely read against a reseller root that is an
ancestor of the bound organization, and has_perm would answer for the bound
one, union in the user's global permissions, and say yes to every superuser.
Your predicate answers the object-level question too. IsBillingManager asks it
about the request's organization for the coarse gate, and about the object for
the object-level one — about that object's organization for a billing row, and
about the object itself when it is an organization, which is what the write
actions pass (the resolved billing root). So a predicate reading "a member of
this organization holding the grant" is also what refuses a child
organization's administrator on a reseller root's plan.
Organization hierarchies
vinta-django-orgs' organization model has a name and a slug and nothing else,
so the library cannot assume a parent field exists. The default
FlatHierarchy treats every organization as its own billing root. A project
whose organizations nest subclasses the shipped parent-chain walk:
from vinta_billing.hierarchy import ParentFieldHierarchy
class ResellerHierarchy(ParentFieldHierarchy):
parent_field = "parent"
root_flag_field = "can_invite_organizations" # a flagged child pays for itself
The walk is cycle-guarded: parent is user-mutable data, and returning an
arbitrary node from a cycle would leave every organization on it billing against
a different root depending on where the walk started.
Audit
Transitions are published as Django signals rather than written to an audit log
this package would have to invent. See vinta_billing/signals.py.
They are sent inside the caller's transaction, so a receiver that raises rolls
the transition back with it.
REST API
The shipped viewsets are offered as routes rather than a ready-made urls.py,
so you mount them where you want:
from vinta_billing.routing import billing_router, get_extra_patterns
urlpatterns = [
path("api/", include(billing_router().urls)),
*get_extra_patterns(),
]
Mount both halves. The endpoints a router cannot express — the singleton
payment-provider reads, and the two inbound provider webhooks, which carry the
provider slug in the URL — come from get_extra_patterns(), not from the
router. Drop it and you have no webhooks, so no provider callback ever arrives.
Already have a router? register_routes(your_router) puts the shipped viewsets
on it. Either way the router's own mode is respected: nothing here assumes the
regex form, so a DefaultRouter(use_regex_path=False) works as well as the
default, and billing_router(use_regex_path=False) builds one. If your router
was built with trailing_slash=False, pass the same to
get_extra_patterns(trailing_slash=False) — those patterns do not come out of
the router and cannot read the choice off it.
Mounting the routes in a project that has its own tenancy and its own DI
Two things a project usually owns are what stopped these routes from being mounted as they are: how a request says which organization it is acting on, and where services come from. Both are settings now, and both default to what this package did before they existed — so a project that configures neither mounts exactly the classes, and builds them from exactly the container, that it always did.
VINTA_BILLING = {
# Mixed in *front* of every tenant-scoped viewset these routes mount, so
# your resolution runs first and this package reads what it left on the
# request. Default: "vinta_billing.view_mixins.TenantScopedViewMixin",
# which those viewsets already inherit — so the default mixes in nothing.
"VIEW_MIXIN": "myproject.api.TenantScopedViewMixin",
# Where the shipped views and the admin build their services. Names a
# module or an object; the service called `payment_service` is looked up as
# `container.get_payment_service()` when that exists and
# `container.payment_service()` otherwise — the second being what a
# `dependency_injector` container offers, so point this straight at yours.
# Default: "vinta_billing.services.container".
"SERVICE_CONTAINER": "myproject.di.container",
}
VIEW_MIXIN reaches the tenant-scoped viewsets only. The plan catalogue answers
the same for every caller, and the two inbound provider webhooks are
authenticated by a provider signature rather than by a member of anything;
neither takes your scoping.
Your mixin may resolve the organization the way DRF mixins usually do — in
perform_authentication, assigning request.organization and returning
None, which is vinta_orgs.drf.OrganizationScopedAPIViewMixin's shape. Both
mixins then spell resolve_organization and yours wins on name resolution, so
this package reads the request rather than taking that None at face value. No
adapter of your own is needed for it.
Passing a service to a viewset's constructor still wins over the container
(entitlement_service=, payment_service=, subscription_service=,
dunning_service=, payment_provider_resolver=), so a project that injects
them by hand today is unaffected by SERVICE_CONTAINER.
SERVICE_CONTAINER reaches the background jobs too, from 0.6.0 on — the four
sweeps in vinta_billing/jobs.py build their services through
the same lookup the views use. Before that they imported this package's own
factories directly, so a project running its own container got its services on
the request path and a second, parallel set on the beat path. Each
per-subscription job still takes its service as a keyword argument, and passing
one still wins, exactly as it does for a viewset.
The shipped viewsets throttle their write and unauthenticated endpoints through
three ScopedRateThrottle scopes, and DRF raises ImproperlyConfigured for a
scope with no configured rate — so all three need one, or those endpoints answer
500 instead of throttling. The numbers are yours to pick:
REST_FRAMEWORK = {
"DEFAULT_THROTTLE_RATES": {
# Inbound provider callbacks.
"payment-webhook": "120/min",
# The unauthenticated provider read.
"payment-provider": "60/min",
# Plan changes, payment retries, add-on purchases.
"billing-write": "30/min",
},
}
Development
uv sync --all-extras
uv run pytest
uv run tox # the full matrix: py3.11–3.14 x Django 5.2/6.0/6.1
uv run tox -e swapped # the suite against a swapped ORGANIZATION_MODEL
uv run tox -e postgres # the suite against Postgres, for the row locks
uv run pre-commit install
tox -e swapped runs everything again with ORGANIZATION_MODEL pointed at a
project-defined model instead of the one vinta-django-orgs ships. Under the
default settings those are the same class, so a foreign key hardcoded to
vinta_orgs.Organization passes the whole suite and only breaks in a project
that actually swapped the model — this is what catches it.
tox -e postgres runs it against a real database, for the same kind of reason.
The suite is on SQLite by default, and SQLite has no row locks: Django notices
and drops SELECT ... FOR UPDATE rather than raising, so cycle close's lock is
silently not taken there and a concurrency test would pass against no lock at
all. tests/test_cycle_close_concurrency.py skips itself unless the database
really takes the lock. Point the environment at a server first:
docker run --rm -e POSTGRES_PASSWORD=postgres -p 55432:5432 postgres:16-alpine
License
MIT. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file vinta_django_billing-0.7.0.tar.gz.
File metadata
- Download URL: vinta_django_billing-0.7.0.tar.gz
- Upload date:
- Size: 236.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.8 {"installer":{"name":"uv","version":"0.12.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
53cce0727c11422e042aa7bd964c4a65c229199e3c932dd40b683a9fe6736fe2
|
|
| MD5 |
9f425171003ca4492f853270fc321c25
|
|
| BLAKE2b-256 |
e6fc271ad6d6cb034234e7292a4cc7d830db04cc52a770ebffdda2741051899d
|
File details
Details for the file vinta_django_billing-0.7.0-py3-none-any.whl.
File metadata
- Download URL: vinta_django_billing-0.7.0-py3-none-any.whl
- Upload date:
- Size: 263.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.8 {"installer":{"name":"uv","version":"0.12.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e7d40ec7c75f9a0323455795dd09cb0499feed854116dc16c23eb9017bf3ff5f
|
|
| MD5 |
713058b4bf3b2edc4a368d3c467b73d9
|
|
| BLAKE2b-256 |
057a90f511c4ad3469e87ba32511cb011e0e2a840f50a0a1e9ebc16008042c53
|