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¶
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:
- Go to PlanFeature → Add →
code="new.feature",type=boolean. - Go to each SubscriptionPlan → add a PlanFeatureAssignment with the value.
- 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")returnsFalsefor 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.