A Detachable Billing Engine for Django & Ninja
billable is an isolated rights management and payments accounting system designed for Django. It abstracts monetization logic (subscriptions, one-time purchases, trials, quotas) from your core application business logic.
The module provides a single API and accounting layer for different orchestrators (n8n, bots, web), so each can use the same billing flows. Designed to work seamlessly with orchestrators like n8n, and fully usable as a standalone Python service layer.
- Transaction-Based Ledger: All balance changes are recorded as immutable transactions (Credit/Debit).
- Offer System: Flexible product bundles with configurable expiration periods.
- FIFO Consumption: Automatic oldest-first quota consumption.
- Fraud Prevention: Abstract identity hashing for trial abuse protection.
- Detachable Architecture: No foreign keys to your business models (uses metadata).
- Idempotency: Built-in protection against double-spending and duplicate payments.
- Customer Merging: Service and API for consolidating user accounts without data loss.
- REST API: Ready-to-use Django Ninja API for frontend or external orchestrators.
- Normalization Policy: Consistent uppercase (CAPS) storage for technical identifiers (SKU, Product Key) with "silent" API normalization.
-
π Architecture & Design Deep dive into Business Processes, Order Flow, and the Transaction Engine.
-
π API & Models Reference Database schema, Configuration variables, and REST API specification.
-
π Changelog: See repository releases or git history.
Install using pip:
pip install billableOr install directly from Git (if using a private repository):
pip install git+https://github.com/bubinez/billable.gitAdd the app to your installed apps and configure the required settings:
INSTALLED_APPS = [
# ...
"billable",
]
# Required: Security token for the REST API
BILLABLE_API_TOKEN = env("BILLABLE_API_TOKEN", default="change-me-in-production")
# Optional: Defaults to "auth.User"
# BILLABLE_USER_MODEL = "custom_users.User" To avoid AppRegistryNotReady errors (especially in tests), always import models and services from their respective submodules. Never import directly from the root billable package.
# Correct
from billable.models import Product, ExternalIdentity
from billable.services import TransactionService
# Incorrect - will cause AppRegistryNotReady
# from billable import Product, TransactionServiceInclude billable URLs in your main urls.py:
from django.urls import path, include
urlpatterns = [
# Mounts the API at /api/v1/billing/
path("api/v1/billing/", include("billable.urls")),
]Create the tables prefixed with billable_:
python manage.py migrate billableTo migrate existing user identity fields (e.g. telegram_id, chat_id) into ExternalIdentity, run: python manage.py migrate_identities <field> <provider>. See Reference β Management Commands.
To ensure data integrity and simplify searching, billable enforces a strict normalization policy:
- SKU and Product Key: Always stored in UPPERCASE (CAPS).
- API & Services: The system is case-insensitive on input. Any string passed as a SKU or Product Key is automatically converted to uppercase before database lookup or storage ("Silent Normalization").
- Trial Hashing: Exception. To remain compatible with external standards (like Stripe or Google), user identifiers (emails, IDs) are converted to lowercase before SHA-256 hashing in
TrialHistory.
You can use the module directly in your views or Celery tasks without calling the HTTP API.
Checking Quota:
from billable.services import TransactionService
async def generate_pdf_report(user):
# Check if user has the technical resource "pdf_export" available
result = await TransactionService.acheck_quota(user.id, "pdf_export")
if not result["can_use"]:
raise PermissionError(f"Upgrade required: {result['message']}")
# Your logic here...
print("Generating PDF...")
# Consume 1 unit of quota (Atomic & Idempotent)
await TransactionService.aconsume_quota(
user_id=user.id,
product_key="pdf_export",
idempotency_key=f"report_{report_id}"
)Creating a Custom Order:
from billable.services import OrderService
order = await OrderService.acreate_order(
user_id=request.user.id,
items=[
{"sku": "off_premium_pack", "quantity": 1}
],
metadata={"source": "web_checkout"}
)Implementing Trial/Bonus Logic:
billable provides building blocks for fraud prevention and transaction management, but does NOT include business rules for promotions. Here's how to implement trial logic in your application:
from billable.models import Offer, TrialHistory
from billable.services import TransactionService
from asgiref.sync import sync_to_async
async def claim_welcome_trial(user_id: int, telegram_id: str):
"""Example: Grant welcome trial with fraud prevention."""
# 1. Check eligibility using TrialHistory
identities = {"telegram": telegram_id}
if await TrialHistory.ahas_used_trial(identities=identities):
return {"success": False, "reason": "trial_already_used"}
# 2. Find the trial offer (create an Offer with sku="off_welcome_trial" in your DB)
offer = await Offer.objects.aget(sku="off_welcome_trial")
# 3. Grant the offer using TransactionService
batches = await sync_to_async(TransactionService.grant_offer)(
user_id=user_id,
offer=offer,
source="welcome_bonus",
metadata={"identities": identities}
)
# 4. Mark trial as used
await TrialHistory.objects.acreate(
identity_type="telegram",
identity_hash=TrialHistory.generate_identity_hash(telegram_id),
trial_plan_name="Welcome Trial"
)
return {"success": True, "batches": batches}from django.dispatch import receiver
from billable.models import Referral, Offer
from billable.signals import order_confirmed
from billable.services import TransactionService
@receiver(order_confirmed)
def on_first_purchase(sender, order, **kwargs):
# 1. Check if it's the first purchase using your app's logic
# ...
# 2. Find referral
referral = Referral.objects.filter(referee=order.user).first()
# 3. Atomically claim the bonus (returns True only once)
if referral and referral.claim_bonus():
# 4. Grant the reward
offer = Offer.objects.get(sku="referral_reward")
TransactionService.grant_offer(
user_id=referral.referrer_id,
offer=offer,
source="referral_bonus",
metadata={
"referee_id": referral.referee_id, # Required for webhook payload
"order_id": order.id, # Required for webhook payload
}
)Important: When creating a referral bonus transaction, always include referee_id and order_id in the metadata parameter. This ensures that webhook payloads (e.g., referral_bonus_granted events) can include referee_external_id by looking up the referee's ExternalIdentity record. Without these fields in metadata, the webhook will only contain referrer_external_id and referee_external_id will be null.
If you are using n8n or a frontend:
Identify user by external identity (recommended first step):
POST /api/v1/billing/identify
Purchase Flow (Real Money):
- Create Order:
POST /api/v1/billing/ordersSupportsexternal_id+provider. Automatically creates a user if missing. - Confirm Payment:
POST /api/v1/billing/orders/{order_id}/confirmTriggered by your payment webhook. This grants products viaTransactionService.grant_offer(source="purchase").
Exchange Flow (Internal Currency):
- Exchange:
POST /api/v1/billing/exchangeAtomically spends internal currency and grants the target offer. Supportsexternal_id+provider(creates user if missing).
Get Balance:
GET /api/v1/billing/wallet (Headers: Authorization: Bearer <TOKEN>)
Lookup only: returns 404 if the external identity is not registered (no auto-creation).
Catalog:
GET /api/v1/billing/catalogβ list all active offers (or filter by?sku=...&sku=...for bulk lookup)GET /api/v1/billing/catalog/{sku}β get a single offer by SKU
For full API details, see the Reference Guide.