billable 0.2.0
Universal Billing Engine
Requires Python: >=3.12


# Universal Billable Module

**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.

## Status
![Status](https://img.shields.io/badge/Status-Active-success)
![Python](https://img.shields.io/badge/Python-3.13%2B-blue)
![Django](https://img.shields.io/badge/Django-5.0%2B-green)

## Features
- **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.
- **Admin UI**: Built-in Django Admin integration with hierarchical usage reports and offer management.
- **Signals & Webhooks**: Event-driven architecture for integrations (e.g., n8n, Zapier).
- **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.

---

## 🤖 Live Demo

See Billable in action! We've built a demonstration bot using **n8n** and Billable API.

👉 **[Try the Demo Bot (@billable_demo_bot)](https://t.me/billable_demo_bot)**

You can find the source workflow and setup instructions in the examples directory:
📂 **[View n8n Integration Example](examples/n8n/README.md)**

---

## Documentation

- 📘 **[Architecture & Design](doc/architecture.md)**
  Deep dive into Business Processes, Order Flow, and the Transaction Engine.
  
- 📙 **[API & Models Reference](doc/reference.md)**
  Database schema, Configuration variables, and REST API specification.

- 📋 **Changelog**: See repository releases or git history.

---

## Installation

Install using pip:

```bash
pip install billable
```

Or install directly from Git (if using a private repository):

```bash
pip install git+https://github.com/bubinez/billable.git
```

## Configuration

### 1. Update `settings.py`

Add the app to your installed apps and configure the required settings:

```python
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" 
```

### 2. Import Policy

To avoid `AppRegistryNotReady` errors (especially in tests), **always** import models and services from their respective submodules. **Never** import directly from the root `billable` package.

```python
# Correct
from billable.models import Product, ExternalIdentity
from billable.services import TransactionService

# Incorrect - will cause AppRegistryNotReady
# from billable import Product, TransactionService
```

### 3. Configure URLs

Include billable URLs in your main `urls.py`:

```python
from django.urls import path, include

urlpatterns = [
    # Mounts the API at /api/v1/billing/
    path("api/v1/billing/", include("billable.urls")),
]
```

### 3. Run Migrations

Create the tables prefixed with `billable_`:

```bash
python manage.py migrate billable
```

To 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](doc/reference.md#management-commands).

### 4. Data Normalization (CAPS Policy)

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`.

---

## Quick Start

### Python Service Layer (Internal Usage)
You can use the module directly in your views or Celery tasks without calling the HTTP API.

**Checking Quota:**
```python
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:**
```python
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:

### Example 1: Welcome Trial (One-time bonus)

```python
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}
```

### Example 2: Referral Bonus (Signal-based)

```python
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`.

### REST API Usage
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):**
1.  **Create Order**: `POST /api/v1/billing/orders`
    *Supports `external_id` + `provider`. Automatically creates a user if missing.*
2.  **Confirm Payment**: `POST /api/v1/billing/orders/{order_id}/confirm`
    *Triggered by your payment webhook. This grants products via `TransactionService.grant_offer(source="purchase")`.*

**Exchange Flow (Internal Currency):**
1.  **Exchange**: `POST /api/v1/billing/exchange`
    *Atomically spends internal currency and grants the target offer. Supports `external_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](doc/reference.md).*