# django-stripe-billing

A reusable Django app for Stripe Checkout billing. Handles webhook intake,
idempotency, async Celery processing, checkout sessions, billing portal, and
Django signals — so each of your SaaS projects gets billing in minutes, not days.

## Features

- ✅ **Stripe Checkout** — `create_checkout_session()` helper
- ✅ **Customer Portal** — `create_billing_portal_session()` helper
- ✅ **Subscription management** — cancel, change plan helpers
- ✅ **Webhook receiver** — signature verification, livemode guard, idempotency
- ✅ **Async processing** — Celery task with exponential backoff on transient errors
- ✅ **Django signals** — connect custom logic per event type without touching the package
- ✅ **Outgoing webhooks** — Zapier / Make / n8n integration per event type
- ✅ **Admin** — `StripeWebhookEvent` with status badges and full-text search
- ✅ **Python 3.10+, Django 4.2+**

## Quick Start

### 1. Install

```bash
pip install django-stripe-billing
# Optional: async webhook processing (recommended for production)
pip install django-stripe-billing[celery]
# or, during development:
pip install -e /path/to/django-stripe-billing
```

Without the `[celery]` extra, webhooks are processed synchronously in the request. With `[celery]`, processing runs in a Celery task for a fast HTTP response and automatic retries.

### 2. Add to INSTALLED_APPS

```python
INSTALLED_APPS = [
    ...
    'django_stripe_billing',
]
```

### 3. Configure

```python
# settings.py
STRIPE_BILLING = {
    'STRIPE_SECRET_KEY': env('STRIPE_SECRET_KEY'),
    'STRIPE_WEBHOOK_SECRET': env('STRIPE_WEBHOOK_SECRET'),
    'STRIPE_MODE': 'live',           # 'live' or 'test'
    'ENVIRONMENT': env('ENVIRONMENT', default='production'),
    'APP_TITLE': 'My SaaS',
    'APP_DOMAIN': 'https://myapp.com',
    'SUPPORT_EMAIL': 'support@myapp.com',
    'EMAIL_SUBJECT_PREFIX': '[My SaaS]',
    # Optional: outgoing webhooks for Zapier / Make / n8n
    'OUTGOING_WEBHOOK_URLS': {
        'invoice.payment_succeeded': env('WEBHOOK_PAYMENT_SUCCEEDED', default=''),
        'invoice.payment_failed': env('WEBHOOK_PAYMENT_FAILED', default=''),
        'customer.subscription.deleted': env('WEBHOOK_SUBSCRIPTION_DELETED', default=''),
    },
}
```

> **Zero-config migration**: If you already have `STRIPE_SECRET_KEY`,
> `STRIPE_WEBHOOK_SECRET`, etc. as top-level settings, the package reads
> them automatically — no changes required.
>
> **Required for production**: `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` must be non-empty when using checkout/portal or the webhook endpoint; otherwise the app raises `ImproperlyConfigured`. See [INTEGRATION.md](INTEGRATION.md) for adding the package to existing projects (e.g. mobileapi_dev, signupcheck, tpscheck_uk).

### 4. Add URL

```python
# urls.py
from django.urls import path, include

urlpatterns = [
    path('webhooks/', include('django_stripe_billing.urls')),
    # Exposes: POST /webhooks/stripe/
]
```

### 5. Run migrations

```bash
python manage.py migrate
```

---

## Creating a Checkout Session

```python
from django_stripe_billing.checkout import create_checkout_session

session = create_checkout_session(
    price_id='price_1Nxyz...',
    success_url=request.build_absolute_uri('/payment-success/?session_id={CHECKOUT_SESSION_ID}'),
    cancel_url=request.build_absolute_uri('/billing/'),
    user=request.user,
    metadata={'plan_id': 'pro_monthly'},
    subscription_metadata={'plan_id': 'pro_monthly', 'new_signup': 'true'},
)
return redirect(session.url, code=303)
```

## Opening the Customer Portal

```python
from django_stripe_billing.checkout import create_billing_portal_session

session = create_billing_portal_session(
    customer_id=request.user.userprofile.stripe_customer_id,
    return_url=request.build_absolute_uri('/billing/'),
)
return redirect(session.url, code=303)
```

---

## Connecting Business Logic via Signals

Each Stripe event fires a Django signal. Connect your app-specific logic here:

```python
# myapp/signals.py
from django.dispatch import receiver
from django_stripe_billing.signals import (
    payment_succeeded,
    payment_failed,
    subscription_deleted,
    trial_will_end,
)
from myapp.models import UserProfile


@receiver(payment_succeeded)
def on_payment_succeeded(sender, invoice, **kwargs):
    customer_id = invoice.get('customer')
    try:
        profile = UserProfile.objects.get(stripe_customer_id=customer_id)
    except UserProfile.DoesNotExist:
        return
    # Update plan, reset credits, etc.
    profile.plan = _resolve_plan(invoice)
    profile.save()


@receiver(payment_failed)
def on_payment_failed(sender, invoice, attempt_count, **kwargs):
    customer_id = invoice.get('customer')
    try:
        profile = UserProfile.objects.get(stripe_customer_id=customer_id)
    except UserProfile.DoesNotExist:
        return
    if attempt_count == 1:
        send_payment_failed_email_1(profile.user)
    elif attempt_count == 2:
        send_payment_failed_email_2(profile.user)


@receiver(subscription_deleted)
def on_subscription_deleted(sender, subscription, **kwargs):
    customer_id = subscription.get('customer')
    try:
        profile = UserProfile.objects.get(stripe_customer_id=customer_id)
    except UserProfile.DoesNotExist:
        return
    old_plan = profile.plan
    profile.plan = 'free'
    profile.save()
    send_subscription_cancelled_email(profile.user, old_plan)
```

Register your signal receivers in `apps.py`:

```python
# myapp/apps.py
class MyAppConfig(AppConfig):
    name = 'myapp'

    def ready(self):
        import myapp.signals  # noqa: F401
```

---

## Available Signals

| Signal | kwargs |
|--------|--------|
| `payment_succeeded` | `invoice`, `user`, `user_payment` |
| `payment_failed` | `invoice`, `user`, `attempt_count` |
| `checkout_completed` | `session`, `user_payment` |
| `subscription_updated` | `subscription`, `user` |
| `subscription_deleted` | `subscription`, `user`, `old_plan` |
| `charge_refunded` | `charge`, `user`, `amount_display` |
| `trial_will_end` | `subscription`, `user`, `trial_end_date` |
| `subscription_paused` | `subscription`, `user` |
| `payment_method_attached` | `payment_method`, `user` |

> `user` and `user_payment` are `None` in the default handlers — they are
> provided by your signal receiver after looking up models from `customer_id`.

---

## Custom Webhook Handlers

Override or add handlers to the registry:

```python
from django_stripe_billing.webhooks import registry

@registry.handler('invoice.payment_succeeded')
def my_custom_handler(data_object):
    # This replaces the built-in handler for this event type
    ...

# Or register a handler for an event type not built-in:
@registry.handler('customer.created')
def handle_customer_created(data_object):
    ...
```

---

## Subscription Helpers

```python
from django_stripe_billing.checkout import cancel_subscription, change_subscription_price

# Cancel at end of current period (default)
cancel_subscription(customer_id='cus_...')

# Cancel immediately
cancel_subscription(customer_id='cus_...', at_period_end=False)

# Change plan
change_subscription_price(customer_id='cus_...', new_price_id='price_new...')
```

---

## Architecture

```
django_stripe_billing/
├── conf.py          # Settings helper (STRIPE_BILLING dict + legacy fallback)
├── models.py        # StripeWebhookEvent (idempotency + audit trail)
├── signals.py       # Django signals, one per Stripe event type
├── webhooks.py      # HandlerRegistry + built-in handlers
├── checkout.py      # create_checkout_session(), create_billing_portal_session(), etc.
├── utils.py         # number_to_currency(), fire_outgoing_webhook()
├── tasks.py         # Celery task: async processing + exponential backoff
├── views.py         # HTTP webhook receiver (signature verify, livemode guard)
├── admin.py         # StripeWebhookEvent admin
└── urls.py          # path('stripe/', stripe_webhook)
```

**How a webhook flows through the package:**

```
Stripe → POST /webhooks/stripe/
  ↓ views.py — verify signature, guard livemode, persist StripeWebhookEvent
  ↓ tasks.py — Celery task picks up event, calls registry.dispatch()
  ↓ webhooks.py — built-in handler fires Django signal + outgoing webhook
  ↓ your app — signal receiver implements business logic
```

---

## Integration into existing projects

If your project already uses Stripe with top-level settings (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_MODE`, etc.), see **[INTEGRATION.md](INTEGRATION.md)** for step-by-step integration with minimal change, including projects that already have a custom webhook handler or Celery.

## Stripe best practices (built in)

- **Webhook signature verification** — All requests are verified with `STRIPE_WEBHOOK_SECRET`; invalid payloads return 400.
- **Livemode guard** — Test events are ignored in production and live events in non-production, so keys and environment stay consistent.
- **Idempotency** — Events are stored by `stripe_event_id`; duplicates return 200 without reprocessing.
- **Never rely only on client-side success** — Use the webhook and signals to update plans and fulfil access; see [Stripe docs](https://stripe.com/docs/webhooks).

## Development

```bash
git clone https://github.com/visian-systems/django-stripe-billing
cd django-stripe-billing
pip install -e ".[dev]"
# With Celery for local webhook testing:
pip install -e ".[dev,celery]"
pytest
```

### Running tests

From the project root:

```bash
pytest
```

For verbose output and short tracebacks:

```bash
pytest -v --tb=short
```

Tests use an in-memory SQLite database and mock Stripe/HTTP; no real API keys are required. See **[TESTING.md](TESTING.md)** for layout, options, and TDD notes.

## License

MIT
