Subscriptions Module

The subscriptions module powers paid plans, billing and per-plan feature flags across the platform. It lives in the App (swapps-app / IDK) as the Django app idk/subscriptions/, integrates with Stripe for checkout and billing, and is the destination of the checkout flow that starts on the Website.

Where this fits in the platform

The Website (swapps-client) renders pricing and starts checkout, calling the App at app.swapps.com (SUBSCRIPTIONS_API_URL) — see Service Communication. Stripe delivers billing events back to the App via inbound webhooks, which require a Cloudflare WAF Skip exception — see Cloudflare Edge. This page documents the module's internal design.


High-level flow

sequenceDiagram participant U as User (browser) participant W as Website (swapps-client) participant A as App (Django · subscriptions) participant S as Stripe U->>W: View pricing / choose plan W->>A: GET subscription plans A-->>W: plans (+ Stripe price links) U->>W: Start checkout W->>A: POST checkout-session A->>S: StripeService.create_checkout_session(...) S-->>U: Stripe Checkout (hosted) S->>A: Webhook checkout.session.completed A->>A: Create Subscription + PaymentHistory A->>A: Activate account (Celery task) Note over A,S: Renewals, failures, plan changes and cancellations
all arrive as Stripe webhooks (signature-verified, idempotent)

1. Django app structure

idk/subscriptions/
├── __init__.py
├── apps.py                  # SubscriptionsConfig
├── models.py                # 6 models (Plan, Feature, Assignment, Subscription, Payment, Event)
├── admin.py                 # Admin with inlines for feature assignments
├── middleware.py            # SubscriptionMiddleware
├── webhooks.py              # Stripe webhook handler
├── services.py              # Business logic (activate account, process payments, etc.)
├── tasks.py                 # Celery tasks (grace period check, emails)
├── urls.py                  # Routes: webhook endpoint, portal, reactivation
├── views.py                 # Subscription management views
├── templatetags/
│   └── subscription_tags.py # Template tags: {% has_feature "code" %}
├── migrations/
│   ├── 0001_initial.py
│   └── 0002_seed_plans_and_features.py  # Data migration with initial plans/features
└── tests/
    ├── test_models.py
    ├── test_webhooks.py
    ├── test_middleware.py
    └── test_features.py

2. Stripe integration decision

Decision: own Django models + the stripe SDK directly.

dj-stripe is not used (it syncs ~30 Stripe tables into the DB). The module only needs subscriptions with full control and simple queries.

Dependency:

# requirements/base.txt
stripe>=8.0.0

All Stripe communication goes through a single service layer — Django models never call Stripe directly:

import logging

import stripe
from django.conf import settings

logger = logging.getLogger(__name__)


class StripeService:
    """
    Service layer between IDK and Stripe API.
    All Stripe communication goes through here.
    Django models do NOT call Stripe directly.
    """

    def __init__(self):
        self.stripe = stripe
        self.stripe.api_key = settings.STRIPE_SECRET_KEY

    def create_checkout_session(self, plan, billing_interval, success_url, cancel_url, client_email=None):
        """Create a Stripe Checkout session for a new subscriber."""
        price_id = (
            plan.stripe_price_id_monthly
            if billing_interval == "monthly"
            else plan.stripe_price_id_yearly
        )
        params = {
            "mode": "subscription",
            "line_items": [{"price": price_id, "quantity": 1}],
            "success_url": success_url,
            "cancel_url": cancel_url,
            "subscription_data": {
                "metadata": {"plan_slug": plan.slug},
            },
        }
        if client_email:
            params["customer_email"] = client_email
        if plan.trial_days > 0:
            params["subscription_data"]["trial_period_days"] = plan.trial_days

        logger.info("Creating checkout session for plan=%s interval=%s", plan.slug, billing_interval)
        return self.stripe.checkout.Session.create(**params)

    def create_billing_portal_session(self, stripe_customer_id, return_url):
        """Create a Stripe billing portal session for the client to manage their payment method."""
        logger.info("Creating billing portal session for customer=%s", stripe_customer_id)
        return self.stripe.billing_portal.Session.create(
            customer=stripe_customer_id,
            return_url=return_url,
        )

    def retrieve_subscription(self, stripe_subscription_id):
        """Retrieve a Stripe subscription with expanded data."""
        return self.stripe.Subscription.retrieve(
            stripe_subscription_id,
            expand=["latest_invoice", "customer"],
        )

    def retrieve_customer(self, stripe_customer_id):
        """Retrieve Stripe customer data (name, email, etc.)."""
        return self.stripe.Customer.retrieve(stripe_customer_id)

    def cancel_subscription(self, stripe_subscription_id, at_period_end=True):
        """Cancel a Stripe subscription."""
        logger.info(
            "Canceling subscription=%s at_period_end=%s",
            stripe_subscription_id,
            at_period_end,
        )
        if at_period_end:
            return self.stripe.Subscription.modify(
                stripe_subscription_id,
                cancel_at_period_end=True,
            )
        return self.stripe.Subscription.cancel(stripe_subscription_id)

    def construct_webhook_event(self, payload, sig_header):
        """Validate and construct a Stripe webhook event."""
        return self.stripe.Webhook.construct_event(
            payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
        )

What comes from the Stripe API vs. what is stored locally

Stripe remains the source of truth; the App keeps a local snapshot for simple queries.

Stripe API (source of truth)            IDK DB (local snapshot)
═══════════════════════════             ═══════════════════════
customer.name                    →     Client.name (already exists)
customer.email                   →     Client.email (already exists)
customer.id                      →     Client.stripe_customer_id (NEW)
subscription.id                  →     Subscription.stripe_subscription_id
subscription.status              →     Subscription.status
subscription.current_period_*    →     Subscription.current_period_*
invoice.amount_paid              →     PaymentHistory.amount
invoice.id                       →     PaymentHistory.stripe_invoice_id
payment_intent.last_payment_error →    PaymentHistory.failure_code/message
───────────────────────────────────────────────────────────────
customer.payment_methods         ✗     NEVER stored (PCI compliance)
invoice.invoice_pdf              ✗     Queried on-demand
billing_portal.session           ✗     Created on-demand

3. Settings & URLs

# config/settings/common.py
LOCAL_APPS = [
    ...
    "idk.subscriptions.apps.SubscriptionsConfig",  # ADD
]

# Stripe SDK
STRIPE_SECRET_KEY = env("STRIPE_SECRET_KEY", default="")
STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", default="")
STRIPE_PUBLISHABLE_KEY = env("STRIPE_PUBLISHABLE_KEY", default="")
# config/urls.py
urlpatterns = [
    ...
    path("subscription/", include("idk.subscriptions.urls", namespace="subscriptions")),
]

Secrets

STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET and STRIPE_PUBLISHABLE_KEY are environment variables — names only here, never commit values.


4. Feature flags system

Concept

A per-plan permission system managed from Django Admin. It answers a single question: "does this client's plan include this feature?"

Full flow

                    DJANGO ADMIN (configuration)
                    ════════════════════════════
                              │
           ┌──────────────────┼──────────────────┐
           ▼                  ▼                  ▼
    ┌─────────────┐  ┌──────────────┐  ┌──────────────────────┐
    │ PlanFeature │  │ Subscription │  │ PlanFeatureAssignment │
    │             │  │    Plan      │  │    (bridge table)     │
    │ code:       │  │              │  │                       │
    │ "reports.   │  │ name: "Pro"  │  │ plan=Pro              │
    │  export_pdf"│  │ slug: "pro"  │  │ feature=export_pdf    │
    │ type:       │  │ price: $49   │  │ value="true"          │
    │  "boolean"  │  │              │  │                       │
    └─────────────┘  └──────────────┘  └──────────────────────┘
                              │
                              │ FK (plan)
                              ▼
                    ┌──────────────────┐
                    │   Subscription   │   ← links Client + Plan
                    │                  │
                    │ client: Acme Inc │
                    │ plan: Pro        │
                    │ status: active   │
                    └──────────────────┘
                              │
                              │ .has_feature("reports.export_pdf")
                              ▼
                    ┌──────────────────┐
                    │   RESULT         │
                    │                  │
                    │ Looks up in      │
                    │ PlanFeature      │
                    │ Assignment:      │
                    │ Pro + export_pdf │
                    │ → value="true"   │
                    │ → return True    │
                    └──────────────────┘

Feature types

Type Stored in value Example Query method
boolean "true" / "false" Can export PDF? subscription.has_feature("reports.export_pdf")True/False
limit "25" (number as string) Max active projects subscription.get_feature_limit("projects.max_active")25
tier "premium" (free text) Support level subscription.get_feature_value("support.level")"premium"

Usage in code

In a View (check access to a feature):

class ReportExportView(LoginRequiredMixin, View):
    def get(self, request):
        subscription = request.user.get_active_subscription()

        # Legacy user without subscription → full access
        if subscription is None:
            return self.generate_report()

        # User with subscription → check feature
        if not subscription.has_feature("reports.export_pdf"):
            return HttpResponseForbidden("Tu plan no incluye exportación PDF")

        return self.generate_report()

In a Template (show/hide elements):

{% load subscription_tags %}

{% has_feature request.user "integrations.slack" as can_use_slack %}
{% if can_use_slack %}
    <button>Configurar Slack</button>
{% else %}
    <button disabled>Slack (disponible en plan Pro)</button>
{% endif %}

{% feature_limit request.user "projects.max_active" as max_projects %}
<p>Proyectos: {{ current_count }} / {{ max_projects }}</p>

In the Middleware (general access gate — does not check individual features):

# SubscriptionMiddleware does NOT check individual features.
# It only checks whether the subscription allows general access:
#   - active/trialing/past_due → access
#   - suspended/canceled       → redirect to reactivation page
#   - no subscription (legacy) → full access

In an API View (enforce a limit):

class ProjectCreateAPIView(APIView):
    def post(self, request):
        subscription = request.user.get_active_subscription()

        if subscription:
            max_projects = subscription.get_feature_limit("projects.max_active")
            current_count = Project.objects.filter(company__in=request.user.company.all()).count()

            if max_projects > 0 and current_count >= max_projects:
                return Response(
                    {"error": f"Tu plan permite máximo {max_projects} proyectos"},
                    status=403
                )

        # Create project...

Configuring from Django Admin

Each SubscriptionPlan carries inline feature assignments:

┌─────────────────────────────────────────────────────────────┐
│ SubscriptionPlan: Professional                     [Save]   │
├─────────────────────────────────────────────────────────────┤
│ Name: Professional                                          │
│ Slug: professional                                          │
│ Stripe Product ID: prod_Abc123                              │
│ Price Monthly: $49.00    Price Yearly: $470.00              │
│ Max Users: 10    Max Projects: 25    Max Contracts: 15      │
│ Trial Days: 14    Grace Period Days: 7                      │
├─────────────────────────────────────────────────────────────┤
│ FEATURE ASSIGNMENTS (inline)                                │
│ ┌────────────────────────┬──────────┬───────────┐           │
│ │ Feature                │ Type     │ Value     │           │
│ ├────────────────────────┼──────────┼───────────┤           │
│ │ reports.export_pdf     │ boolean  │ true      │           │
│ │ reports.scheduled      │ boolean  │ true      │           │
│ │ integrations.clickup   │ boolean  │ true      │           │
│ │ integrations.quickbooks│ boolean  │ false     │           │
│ │ integrations.slack     │ boolean  │ true      │           │
│ │ projects.max_active    │ limit    │ 25        │           │
│ │ support.level          │ tier     │ email     │           │
│ │ api.access             │ boolean  │ true      │           │
│ │ [+ Add another]        │          │           │           │
│ └────────────────────────┴──────────┴───────────┘           │
└─────────────────────────────────────────────────────────────┘

To add a new feature to the system:

  1. Go to PlanFeature → Add → code="new.feature", type=boolean.
  2. Go to each SubscriptionPlan → add a PlanFeatureAssignment with the value.
  3. In code, use subscription.has_feature("new.feature").

No deploy. No migrations. Everything from the admin.

Global kill switch

Each PlanFeature has an is_active field. If disabled:

  • has_feature("code") returns False for all plans.
  • Useful to turn a feature off in an emergency without editing every plan.

5. Compatibility with existing (legacy) clients

                 EXISTING CLIENT                     NEW CLIENT
                 (pre-subscription)                  (with subscription)
                 ══════════════════                  ═══════════════════

  Client                                    Client
  ├─ name: "Acme Corp"                      ├─ name: "NewCo"
  ├─ stripe_customer_id: NULL  ← none       ├─ stripe_customer_id: "cus_xxx"
  └─ subscriptions: []  ← empty             └─ subscriptions: [Subscription]
                                                  ├─ plan: "Pro"
  Company                                         └─ status: "active"
  ├─ client: → Acme Corp
  ├─ subscription: NULL  ← none             Company
  └─ status: "contract"                     ├─ client: → NewCo
                                            ├─ subscription: → Subscription
  User                                      └─ status: "contract"
  ├─ companies: [Acme Corp]
  └─ get_active_subscription()              User
       → walks companies                    ├─ companies: [NewCo]
       → company.client.active_sub          └─ get_active_subscription()
       → None                                    → walks companies
                                                 → company.client.active_sub
  has_subscription_access:                       → Subscription(status=active)
       sub is None → return True ✓
       (full access, no restrictions)       has_subscription_access:
                                                 sub.has_access → True ✓
  has_feature("reports.export_pdf"):
       sub is None                          has_feature("reports.export_pdf"):
       → feature check does not apply             sub.plan = "Pro"
       → full access ✓                            → PlanFeatureAssignment lookup
                                                  → value="true" → True ✓

Golden rule

subscription = None means "legacy client → full access, no restrictions".


6. Middleware

class SubscriptionMiddleware:
    """
    Intercepts requests and checks subscription status.

    Decision flow:

    Incoming request
         │
         ▼
    Authenticated? ──No──→ Pass (login handled by allauth)
         │
        Yes
         │
    Exempt path? ──Yes──→ Pass (admin, webhooks, static, login)
         │
        No
         │
    user.has_subscription_access?
         │
        Yes ──→ Pass (normal access)
         │
        No ──→ Redirect to /subscription/suspended/
              (page with status info + reactivate button)
    """

    EXEMPT_PATHS = [
        "/accounts/",           # Login/logout (allauth)
        "/admin/",              # Django admin
        "/api/webhooks/",       # Stripe webhooks
        "/subscription/",       # Subscription management
        "/static/",
        "/media/",
        "/__debug__/",
    ]

7. Stripe webhooks — internal flow

The webhook endpoint validates the Stripe signature (STRIPE_WEBHOOK_SECRET) and is idempotent (every event creates a SubscriptionEvent keyed by stripe_event_id).

Cloudflare WAF Skip required

Stripe delivers events server-to-server, so the inbound route on app.swapps.com needs a Cloudflare WAF Skip exception (Super Bot Fight Mode) — see Cloudflare Edge. The platform's skip rule matches the Stripe webhook path under /subscriptions/webhooks/stripe.

# subscriptions/webhooks.py

@csrf_exempt
@require_POST
def stripe_webhook(request):
    """
    Endpoint: POST /subscription/webhook/stripe/

    Events we process:

    checkout.session.completed
        → First successful payment
        → Create Subscription + PaymentHistory
        → Activate account (Celery task)

    invoice.payment_succeeded
        → Successful renewal
        → Update current_period_end
        → Create PaymentHistory(type=renewal)
        → Clear grace_period if it existed

    invoice.payment_failed
        → Failed payment
        → Create PaymentHistory(type=retry, status=failed)
        → Start grace period if none exists
        → Notify client (Celery task)

    customer.subscription.updated
        → Plan change (upgrade/downgrade)
        → Update Subscription.plan
        → Create SubscriptionEvent(plan.upgraded/downgraded)

    customer.subscription.deleted
        → Subscription canceled for good
        → Subscription.cancel()
        → Revoke access
        → Notify client

    All events:
        → Create SubscriptionEvent with stripe_event_id (idempotency)
        → Verify webhook signature (STRIPE_WEBHOOK_SECRET)
    """

8. Celery tasks

# subscriptions/tasks.py

@shared_task
def check_grace_periods():
    """
    Runs hourly via django-celery-beat.
    Finds past_due subscriptions whose grace_period_end has passed
    and suspends them automatically.
    """

@shared_task
def send_payment_failed_email(subscription_id):
    """Emails the client when a payment fails."""

@shared_task
def send_welcome_email(subscription_id):
    """Emails the welcome message when the account is activated."""

@shared_task
def send_subscription_canceled_email(subscription_id):
    """Emails the client when the subscription is canceled."""

@shared_task
def send_grace_period_reminder(subscription_id):
    """
    Sends a reminder N days before the grace period expires.
    Includes a link to the Stripe Customer Portal to update the payment method.
    """

9. API

GET Subscription Plan List

Returns all subscription plans configured by the administrator.

Method GET
Common use Pricing pages

Response includes:

  • Subscription plans configured by the administrator
  • Prices for each plan
  • Stripe payment links for billing — monthly and yearly

Example response (suggested structure):

{
  "plans": [
    {
      "id": "plan_001",
      "name": "Basic",
      "price_monthly": 9.99,
      "price_yearly": 99.99,
      "stripe_link_monthly": "https://buy.stripe.com/...",
      "stripe_link_yearly": "https://buy.stripe.com/..."
    }
  ]
}

Source

This page is adapted from the internal ClickUp design doc "Modulo Suscripciones". When the implementation and the design doc diverge, the code in idk/subscriptions/ is authoritative.