Como Construir um SaaS Pronto para Produção com Next.js, Django, Stripe e Multi-Tenancy no PostgreSQL

Published on: 2026-01-07
Post image
pt saas arquitetura-saas nextjs django django-tenants multi-tenancy multi-tenant-postgres postgresql stripe stripe-subscriptions pagamentos-recorrentes saas-com-assinatura backend-django frontend-nextjs api-rest docker docker-compose

Uma aplicação SaaS (Software como Serviço) é um sistema entregue pela internet, normalmente por assinatura, no qual diferentes empresas usam o mesmo produto com seus próprios dados e configurações. Para que isso funcione com segurança e estabilidade em produção, torna-se necessário combinar uma arquitetura sólida, pagamentos confiáveis, isolamento entre clientes e um processo de entrega automatizado.

Uma arquitetura moderna e comum combina Next.js no front-end, Django no back-end, Stripe para cobrança recorrente, PostgreSQL como banco e um modelo multi-tenant (multi-inquilino) para separar os dados de cada empresa. Além disso, a operação em produção costuma usar Docker, rotinas assíncronas com Celery, cache com Redis, CI/CD (Integração e Entrega Contínuas) e práticas de segurança, observabilidade e testes.

Visão geral da arquitetura: componentes e responsabilidades

Uma arquitetura “pronta para produção” separa claramente responsabilidades entre interface, API, cobrança e infraestrutura. O Next.js atua como camada de apresentação, com páginas, navegação e componentes, enquanto o Django fornece uma API robusta e centraliza regras de negócio. O Stripe gerencia cartão, assinatura, faturas e eventos de pagamento, e o banco PostgreSQL armazena dados com consistência.

O conceito de multi-tenancy significa que o mesmo sistema atende várias empresas, mas com separação lógica e regras de acesso. Uma abordagem forte é o isolamento por schema (esquema) no PostgreSQL, em que cada empresa possui um esquema próprio com tabelas equivalentes. Isso reduz risco de vazamento acidental e facilita governança de dados, ao custo de maior complexidade operacional.

Para operar em escala, entram Docker para padronizar execução, GitHub Actions para automação de testes e deploy, e um proxy como Nginx para roteamento por domínio e subdomínio. Também são comuns tarefas assíncronas com Celery, como sincronizar status de assinaturas, emitir relatórios e lidar com rotinas de manutenção. Observabilidade com logs, health checks e monitoramento fecha o conjunto.

Multi-tenancy por schema no Django com django-tenants

O isolamento por schema cria uma barreira natural entre os dados de cada cliente, pois cada tenant (empresa) tem tabelas separadas. No django-tenants, o tenant costuma ser um modelo que herda TenantMixin e possui informações de identidade e configuração. Já o domínio costuma usar DomainMixin para mapear subdomínios e domínios ao tenant correto.

Esse modelo permite que a mesma API responda de forma diferente conforme o host (domínio) da requisição. Um subdomínio como “empresa1.seudominio.com” pode apontar para o schema “empresa1”, enquanto “empresa2.seudominio.com” aponta para outro schema. Assim, os dados da empresa1 não se misturam com os da empresa2, mesmo que as duas usem o mesmo código e o mesmo cluster de banco.

A seguir está um exemplo completo de modelos para tenant e domínio, incluindo campos para relacionamento com o Stripe e limites de plano. Ele mostra como o tenant pode carregar identificadores de cobrança, status e parâmetros de uso que o back-end valida antes de permitir certas operações.

from django.db import models
from django_tenants.models import TenantMixin, DomainMixin


class Tenant(TenantMixin):
    name = models.CharField(max_length=100)
    slug = models.SlugField(unique=True)
    created_on = models.DateField(auto_now_add=True)

    # Integração com Stripe
    stripe_customer_id = models.CharField(max_length=255, blank=True)
    stripe_subscription_id = models.CharField(max_length=255, blank=True)
    subscription_status = models.CharField(max_length=50, default="trial")

    # Limites do plano
    max_users = models.IntegerField(default=5)
    storage_limit_gb = models.IntegerField(default=10)

    auto_create_schema = True

    def __str__(self):
        return self.name


class Domain(DomainMixin):
    pass

Configuração do Django: apps compartilhados, apps do tenant e banco

Ao usar django-tenants, as aplicações são divididas em SHARED_APPS e TENANT_APPS. As apps compartilhadas existem no schema público (public) e tipicamente incluem autenticação, sessões e o app de tenants. As apps do tenant são sincronizadas para cada schema de cliente e carregam dados isolados, como usuários do produto, assinaturas internas e recursos.

A configuração do banco usa um backend específico, django_tenants.postgresql_backend, e um DATABASE_ROUTERS para direcionar migrações e consultas ao schema correto. Isso faz com que, durante uma requisição, o schema ativo seja selecionado e as queries sejam executadas naquele contexto. Em produção, variáveis de ambiente são usadas para credenciais e endereços, reduzindo exposição de segredos no código.

O exemplo abaixo mostra uma configuração base que combina apps, modelo de tenant, roteador e banco PostgreSQL. Ele também sugere uma estrutura de settings separada por ambientes (desenvolvimento e produção), que reduz risco de configurações inseguras em produção.

from decouple import config

SHARED_APPS = [
    "django_tenants",
    "apps.tenants",
    "django.contrib.contenttypes",
    "django.contrib.auth",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.admin",
]

TENANT_APPS = [
    "django.contrib.contenttypes",
    "apps.users",
    "apps.subscriptions",
    "apps.core",
    "rest_framework",
]

INSTALLED_APPS = SHARED_APPS + [app for app in TENANT_APPS if app not in SHARED_APPS]

TENANT_MODEL = "tenants.Tenant"
TENANT_DOMAIN_MODEL = "tenants.Domain"

DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",)

DATABASES = {
    "default": {
        "ENGINE": "django_tenants.postgresql_backend",
        "NAME": config("DB_NAME"),
        "USER": config("DB_USER"),
        "PASSWORD": config("DB_PASSWORD"),
        "HOST": config("DB_HOST", default="localhost"),
        "PORT": config("DB_PORT", default="5432"),
    }
}

Pagamentos com Stripe: clientes, assinaturas e estados de cobrança

O Stripe é uma plataforma de pagamentos que oferece APIs para criar clientes, cobrar assinaturas e emitir faturas. Em um SaaS multi-tenant, cada tenant costuma corresponder a um “Customer” no Stripe, e cada plano corresponde a um “Price” configurado no Stripe. O back-end mantém IDs do Stripe no tenant para reconciliar eventos e estados de pagamento.

Uma prática comum é encapsular chamadas ao Stripe em uma camada de serviço, como StripeService. Isso centraliza tratamento de erros, mapeamento de metadados e atualização de estado local. Também facilita testes, pois as funções podem ser mockadas para simular respostas do Stripe.

O exemplo a seguir cria customer e subscription e atualiza o tenant com IDs e status. Ele também inclui cancelamento, mantendo coerência no status interno, o que é útil para o front-end bloquear funcionalidades conforme o estado da assinatura.

import stripe
from django.conf import settings

stripe.api_key = settings.STRIPE_SECRET_KEY


class StripeService:
    @staticmethod
    def create_customer(tenant, email):
        customer = stripe.Customer.create(
            email=email,
            metadata={"tenant_id": tenant.id, "tenant_slug": tenant.slug},
        )
        tenant.stripe_customer_id = customer.id
        tenant.save(update_fields=["stripe_customer_id"])
        return customer

    @staticmethod
    def create_subscription(tenant, price_id, payment_method_id):
        subscription = stripe.Subscription.create(
            customer=tenant.stripe_customer_id,
            items=[{"price": price_id}],
            default_payment_method=payment_method_id,
            expand=["latest_invoice.payment_intent"],
        )

        tenant.stripe_subscription_id = subscription.id
        tenant.subscription_status = subscription.status
        tenant.save(update_fields=["stripe_subscription_id", "subscription_status"])
        return subscription

    @staticmethod
    def cancel_subscription(tenant):
        if not tenant.stripe_subscription_id:
            return None

        subscription = stripe.Subscription.delete(tenant.stripe_subscription_id)
        tenant.subscription_status = "canceled"
        tenant.save(update_fields=["subscription_status"])
        return subscription

Webhooks do Stripe: sincronização confiável do status da assinatura

Webhooks são chamadas HTTP que o Stripe envia quando algo acontece, como pagamento aprovado, falha de pagamento ou cancelamento. Eles são essenciais porque o status real da cobrança pode mudar fora do fluxo do front-end, como um cartão expirado ou uma fatura paga depois. O webhook deve validar assinatura com o STRIPE_WEBHOOK_SECRET para garantir autenticidade.

O handler costuma receber o evento, verificar o tipo e então chamar funções específicas para atualizar o tenant. É importante tratar erros de payload inválido e assinatura incorreta, retornando status adequados. Também é importante que a lógica seja idempotente, ou seja, suporte eventos repetidos sem gerar inconsistência.

A seguir está um handler completo no padrão Django REST Framework, com validação de assinatura e roteamento por tipo de evento. Ele atualiza o status interno ao receber atualizações ou exclusões de assinatura.

import stripe
from django.conf import settings
from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response

from apps.tenants.models import Tenant


@csrf_exempt
@api_view(["POST"])
@permission_classes([AllowAny])
def stripe_webhook(request):
    payload = request.body
    sig_header = request.META.get("HTTP_STRIPE_SIGNATURE")

    try:
        event = stripe.Webhook.construct_event(
            payload=payload,
            sig_header=sig_header,
            secret=settings.STRIPE_WEBHOOK_SECRET,
        )
    except ValueError:
        return Response({"error": "Payload inválido"}, status=400)
    except stripe.error.SignatureVerificationError:
        return Response({"error": "Assinatura inválida"}, status=400)

    event_type = event.get("type")
    data_object = event.get("data", {}).get("object", {})

    if event_type == "customer.subscription.updated":
        handle_subscription_updated(data_object)
    elif event_type == "customer.subscription.deleted":
        handle_subscription_deleted(data_object)
    elif event_type == "invoice.payment_succeeded":
        handle_payment_succeeded(data_object)
    elif event_type == "invoice.payment_failed":
        handle_payment_failed(data_object)

    return Response({"status": "success"})


def handle_subscription_updated(subscription):
    tenant = Tenant.objects.get(stripe_subscription_id=subscription["id"])
    tenant.subscription_status = subscription["status"]
    tenant.save(update_fields=["subscription_status"])


def handle_subscription_deleted(subscription):
    tenant = Tenant.objects.get(stripe_subscription_id=subscription["id"])
    tenant.subscription_status = "canceled"
    tenant.save(update_fields=["subscription_status"])


def handle_payment_succeeded(invoice):
    # Exemplo comum: registrar evento e manter status como ativo quando aplicável
    subscription_id = invoice.get("subscription")
    if not subscription_id:
        return
    tenant = Tenant.objects.filter(stripe_subscription_id=subscription_id).first()
    if tenant and tenant.subscription_status != "active":
        tenant.subscription_status = "active"
        tenant.save(update_fields=["subscription_status"])


def handle_payment_failed(invoice):
    # Exemplo comum: marcar como "past_due" quando o Stripe indicar falha
    subscription_id = invoice.get("subscription")
    if not subscription_id:
        return
    tenant = Tenant.objects.filter(stripe_subscription_id=subscription_id).first()
    if tenant:
        tenant.subscription_status = "past_due"
        tenant.save(update_fields=["subscription_status"])

API REST no Django: serializers, viewsets e autorização

Uma API REST organiza endpoints que recebem e devolvem JSON, com regras previsíveis. O Django REST Framework oferece serializers para validar dados e viewsets para organizar rotas e ações. Em um SaaS, endpoints de assinatura normalmente exigem autenticação, pois alteram cobrança e estado do tenant.

O serializer do tenant geralmente expõe apenas campos necessários para o front-end, evitando dados sensíveis. Já um serializer de criação de assinatura valida o “price_id” e o “payment_method_id”, garantindo que o back-end não processe payloads incompletos. Essa validação reduz erros e facilita mensagens consistentes.

O exemplo abaixo mostra serializers e um ViewSet com ações para criar, cancelar e buscar assinatura atual do tenant. Ele também demonstra captura de exceções do Stripe e retorno de erro em formato simples.

import stripe
from rest_framework import serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response

from apps.tenants.models import Tenant
from .services import StripeService


class TenantSerializer(serializers.ModelSerializer):
    class Meta:
        model = Tenant
        fields = [
            "id",
            "name",
            "slug",
            "subscription_status",
            "max_users",
            "storage_limit_gb",
            "created_on",
        ]
        read_only_fields = ["id", "created_on", "subscription_status"]


class SubscriptionCreateSerializer(serializers.Serializer):
    price_id = serializers.CharField()
    payment_method_id = serializers.CharField()


class SubscriptionViewSet(viewsets.ViewSet):
    permission_classes = [IsAuthenticated]

    @action(detail=False, methods=["post"])
    def create_subscription(self, request):
        serializer = SubscriptionCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        tenant = request.tenant

        try:
            subscription = StripeService.create_subscription(
                tenant=tenant,
                price_id=serializer.validated_data["price_id"],
                payment_method_id=serializer.validated_data["payment_method_id"],
            )
            return Response({"subscription_id": subscription.id, "status": subscription.status})
        except stripe.error.StripeError as e:
            return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)

    @action(detail=False, methods=["post"])
    def cancel_subscription(self, request):
        tenant = request.tenant
        try:
            StripeService.cancel_subscription(tenant)
            return Response({"status": "canceled"})
        except stripe.error.StripeError as e:
            return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)

    @action(detail=False, methods=["get"])
    def current(self, request):
        tenant = request.tenant
        serializer = TenantSerializer(tenant)
        return Response(serializer.data)

Front-end com Next.js: organização, chamadas à API e autenticação

O Next.js combina renderização no servidor e no cliente, com roteamento por pastas e otimizações automáticas. Um setup comum usa TypeScript para tipagem e TailwindCSS para estilos utilitários, mantendo consistência visual. Além disso, o front-end precisa conversar com o back-end via HTTP e manter tokens de autenticação.

Um API client com Axios centraliza baseURL, headers e interceptors, reduzindo repetição. O interceptor de request costuma anexar o token e também um identificador do tenant, como subdomínio ou header “X-Tenant”. Já o interceptor de response pode lidar com refresh token, renovando o token de acesso ao receber 401.

O exemplo abaixo configura o Axios com cabeçalhos, credenciais e interceptors. Ele inclui extração do subdomínio via hostname e um fluxo básico de renovação de token, reduzindo quedas de sessão em navegação prolongada.

import axios from "axios";

const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";

export const apiClient = axios.create({
  baseURL: API_BASE_URL,
  headers: {
    "Content-Type": "application/json",
  },
  withCredentials: true,
});

apiClient.interceptors.request.use((config) => {
  const token = localStorage.getItem("access_token");
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }

  if (typeof window !== "undefined") {
    const subdomain = window.location.hostname.split(".")[0];
    config.headers["X-Tenant"] = subdomain;
  }

  return config;
});

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const refreshToken = localStorage.getItem("refresh_token");
        const response = await axios.post(`${API_BASE_URL}/api/token/refresh/`, {
          refresh: refreshToken,
        });

        const { access } = response.data;
        localStorage.setItem("access_token", access);

        originalRequest.headers.Authorization = `Bearer ${access}`;
        return apiClient(originalRequest);
      } catch (refreshError) {
        localStorage.removeItem("access_token");
        localStorage.removeItem("refresh_token");
        window.location.href = "/login";
      }
    }

    return Promise.reject(error);
  }
);

export default apiClient;

Stripe no front-end: Stripe.js, Elements e criação de assinatura

No front-end, o Stripe fornece o Stripe.js e os Elements, que são componentes seguros para coletar dados de pagamento sem que o servidor precise lidar diretamente com dados sensíveis. O fluxo típico envolve capturar um método de pagamento e enviar o ID para o back-end criar a assinatura. Em alguns casos, o pagamento exige autenticação adicional, como 3D Secure, e o front-end precisa confirmar o pagamento.

Uma função utilitária para carregar o Stripe com a chave publicável evita recarregar a biblioteca várias vezes. O componente de checkout mantém estados de loading e erro e coordena o envio ao back-end. Esse padrão reduz inconsistências e oferece uma forma clara de controlar falhas de pagamento e validações de cartão.

A seguir estão exemplos completos do carregamento do Stripe e de um formulário de checkout com Elements. Eles demonstram captura do paymentMethod e criação de assinatura na API, com fallback para confirmação do pagamento quando necessário.

import { loadStripe } from "@stripe/stripe-js";

let stripePromise;

export const getStripe = () => {
  if (!stripePromise) {
    stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
  }
  return stripePromise;
};
"use client";

import { useState } from "react";
import { PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
import apiClient from "@/lib/api";

export default function CheckoutForm({ priceId, onSuccess }) {
  const stripe = useStripe();
  const elements = useElements();

  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const handleSubmit = async (e) => {
    e.preventDefault();

    if (!stripe || !elements) {
      return;
    }

    setLoading(true);
    setError(null);

    try {
      const { error: submitError, paymentMethod } = await stripe.createPaymentMethod({
        elements,
      });

      if (submitError) {
        setError(submitError.message || "Falha ao validar o pagamento");
        setLoading(false);
        return;
      }

      const response = await apiClient.post("/api/subscriptions/create_subscription/", {
        price_id: priceId,
        payment_method_id: paymentMethod.id,
      });

      if (response.data.status === "active") {
        onSuccess();
      } else {
        const { error: confirmError } = await stripe.confirmPayment({
          elements,
          confirmParams: {
            return_url: `${window.location.origin}/billing/success`,
          },
        });

        if (confirmError) {
          setError(confirmError.message || "Falha ao confirmar o pagamento");
        }
      }
    } catch (err) {
      setError(err.response?.data?.error || "Erro inesperado ao processar assinatura");
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <PaymentElement />
      </div>

      {error && (
        <div>
          {error}
        </div>
      )}

      <button type="submit" disabled={!stripe || loading}>
        {loading ? "Processando..." : "Assinar"}
      </button>
    </form>
  );
}

Containerização com Docker: backend, frontend, banco e worker

Docker empacota a aplicação e suas dependências em imagens reproduzíveis. Isso reduz diferenças entre ambientes e padroniza execução em produção. No back-end Python, uma imagem costuma instalar dependências, copiar o código e rodar Gunicorn, que é um servidor WSGI robusto para Django.

No front-end Next.js, o padrão profissional é um build multi-stage, gerando uma imagem final enxuta apenas com o necessário para executar. No ambiente completo, o docker-compose pode orquestrar serviços como Postgres, Redis, backend, celery worker e frontend, com variáveis e dependências declaradas.

A seguir estão exemplos completos de Dockerfile do back-end, Dockerfile do front-end e um docker-compose com todos os serviços principais. Eles demonstram portas expostas, volumes e variáveis essenciais para integração Stripe e banco.

FROM python:3.11-slim

ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1

WORKDIR /app

RUN apt-get update && apt-get install -y \
    postgresql-client \
    gcc \
    && rm -rf /var/lib/apt/lists/*

COPY requirements/ requirements/
RUN pip install --no-cache-dir -r requirements/production.txt

COPY . .

RUN python manage.py collectstatic --noinput

EXPOSE 8000

CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
version: "3.8"

services:
  db:
    image: postgres:15
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB=saas_db
      - POSTGRES_USER=saas_user
      - POSTGRES_PASSWORD=secure_password
    ports:
      - "5432:5432"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  backend:
    build:
      context: ./backend
      dockerfile: docker/Dockerfile
    command: gunicorn config.wsgi:application --bind 0.0.0.0:8000
    volumes:
      - ./backend:/app
      - static_volume:/app/staticfiles
    ports:
      - "8000:8000"
    environment:
      - DEBUG=False
      - DATABASE_URL=postgresql://saas_user:secure_password@db:5432/saas_db
      - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
      - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
    depends_on:
      - db
      - redis

  celery:
    build:
      context: ./backend
      dockerfile: docker/Dockerfile
    command: celery -A config worker -l info
    volumes:
      - ./backend:/app
    environment:
      - DATABASE_URL=postgresql://saas_user:secure_password@db:5432/saas_db
    depends_on:
      - db
      - redis

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      - NEXT_PUBLIC_API_URL=http://backend:8000
      - NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
    depends_on:
      - backend

volumes:
  postgres_data:
  static_volume:

CI/CD com GitHub Actions: testes, build de imagens e deploy

CI/CD automatiza validação e entrega: a cada push ou pull request, testes e verificações rodam; quando o branch principal é atualizado, imagens podem ser geradas e publicadas e o deploy é executado. Isso reduz falhas humanas e padroniza releases. Para sistemas com front e back, pipelines separados aumentam clareza e isolam problemas.

No back-end, é comum subir um serviço de Postgres temporário no job e rodar testes do Django. No front-end, rodam lint, testes e build para garantir que o bundle é gerável. Em seguida, um job de build/push pode publicar imagens no registry e um job final de deploy pode executar pull e restart no servidor.

O exemplo abaixo mostra um workflow completo com jobs de teste, build/push e deploy via SSH. Ele ilustra dependências entre jobs e condicionais para executar deploy somente no branch principal.

name: Deploy Application

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  test-backend:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: test_db
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v3

      - name: Configurar Python
        uses: actions/setup-python@v4
        with:
          python-version: "3.11"

      - name: Instalar dependências
        run: |
          cd backend
          pip install -r requirements/development.txt

      - name: Rodar testes
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
        run: |
          cd backend
          python manage.py test

      - name: Lint e formatação
        run: |
          cd backend
          flake8 .
          black --check .

  test-frontend:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Configurar Node.js
        uses: actions/setup-node@v3
        with:
          node-version: "18"
          cache: "npm"
          cache-dependency-path: frontend/package-lock.json

      - name: Instalar dependências
        run: |
          cd frontend
          npm ci

      - name: Lint
        run: |
          cd frontend
          npm run lint

      - name: Testes
        run: |
          cd frontend
          npm run test

      - name: Build
        run: |
          cd frontend
          npm run build

  build-and-push:
    needs: [test-backend, test-frontend]
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'

    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v3

      - name: Login no registry
        uses: docker/login-action@v2
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Metadata backend
        id: meta-backend
        uses: docker/metadata-action@v4
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend

      - name: Build e push backend
        uses: docker/build-push-action@v4
        with:
          context: ./backend
          file: ./backend/docker/Dockerfile
          push: true
          tags: ${{ steps.meta-backend.outputs.tags }}
          labels: ${{ steps.meta-backend.outputs.labels }}

      - name: Metadata frontend
        id: meta-frontend
        uses: docker/metadata-action@v4
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend

      - name: Build e push frontend
        uses: docker/build-push-action@v4
        with:
          context: ./frontend
          file: ./frontend/Dockerfile
          push: true
          tags: ${{ steps.meta-frontend.outputs.tags }}
          labels: ${{ steps.meta-frontend.outputs.labels }}

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'

    steps:
      - name: Deploy em produção
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USERNAME }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            cd /opt/saas-app
            docker-compose pull
            docker-compose up -d
            docker-compose exec backend python manage.py migrate

Roteamento multi-tenant com Nginx: domínio principal e subdomínios

Em produção, o Nginx frequentemente fica na frente do front-end e do back-end, funcionando como proxy reverso. Ele recebe requisições HTTP e encaminha para o serviço correto, além de repassar headers que preservam host e IP original. Em um SaaS com subdomínios, ele também é responsável por aceitar “*.seudominio.com” e direcionar para o mesmo front-end.

Uma separação comum é: domínio raiz para páginas institucionais e subdomínios para o app de cada tenant. A rota “/api/” costuma ser encaminhada ao backend, enquanto “/” vai para o frontend. Também é comum expor “/static/” e “/media/” diretamente, com cache agressivo, reduzindo carga no Django.

O exemplo abaixo mostra dois blocos de server: um para domínio principal e outro para subdomínios. Ele também inclui headers de CORS no caminho da API, útil quando cookies e credenciais são usados em um cenário de múltiplas origens.

upstream backend {
    server backend:8000;
}

upstream frontend {
    server frontend:3000;
}

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    location / {
        proxy_pass http://frontend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

server {
    listen 80;
    server_name *.yourdomain.com;

    location / {
        proxy_pass http://frontend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /api/ {
        proxy_pass http://backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        add_header 'Access-Control-Allow-Origin' '$http_origin' always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Requested-With' always;

        if ($request_method = 'OPTIONS') {
            return 204;
        }
    }

    location /static/ {
        alias /app/staticfiles/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    location /media/ {
        alias /app/media/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}

Tarefas assíncronas com Celery: expiração de trial, sincronização e relatórios

Celery é uma fila de tarefas que executa rotinas fora do ciclo da requisição, evitando bloquear a API. Em SaaS, isso é importante para atividades como verificar expiração de trial, sincronizar status de assinatura com o Stripe e gerar relatórios periódicos. O Celery Beat adiciona agendamento, disparando tarefas em horários e intervalos definidos.

Em sistemas multi-tenant por schema, tarefas de relatório normalmente precisam alternar o contexto do tenant para consultar tabelas isoladas. O django-tenants oferece utilitários como tenant_context para executar queries dentro do schema do tenant. Assim, uma tarefa pode iterar tenants e calcular métricas individualmente sem misturar dados.

O exemplo abaixo reúne tarefas comuns e um agendamento com crontab. Ele mostra como selecionar tenants por status, sincronizar assinaturas e alternar contexto para calcular uso.

from celery import shared_task
from django.utils import timezone
from datetime import timedelta

from apps.tenants.models import Tenant


@shared_task
def check_trial_expiration():
    expiring_soon = Tenant.objects.filter(
        subscription_status="trial",
        created_on__lte=timezone.now().date() - timedelta(days=11),
    )

    for tenant in expiring_soon:
        # Exemplo: notificação por e-mail poderia ser disparada aqui
        pass


@shared_task
def sync_subscription_status():
    import stripe
    from django.conf import settings

    stripe.api_key = settings.STRIPE_SECRET_KEY

    active_tenants = Tenant.objects.exclude(subscription_status__in=["trial", "canceled"])

    for tenant in active_tenants:
        if tenant.stripe_subscription_id:
            subscription = stripe.Subscription.retrieve(tenant.stripe_subscription_id)

            if subscription.status != tenant.subscription_status:
                tenant.subscription_status = subscription.status
                tenant.save(update_fields=["subscription_status"])


@shared_task
def generate_usage_reports():
    from django_tenants.utils import tenant_context
    from django.db.models import Sum

    tenants = Tenant.objects.all()

    for tenant in tenants:
        with tenant_context(tenant):
            from apps.users.models import User
            from apps.core.models import Resource

            user_count = User.objects.count()
            storage_used = (
                Resource.objects.aggregate(total_size=Sum("file_size")).get("total_size") or 0
            )

            # Exemplo: persistir métricas ou enviar relatório
            pass
from celery.schedules import crontab

CELERY_BEAT_SCHEDULE = {
    "check-trial-expiration": {
        "task": "apps.subscriptions.tasks.check_trial_expiration",
        "schedule": crontab(hour=9, minute=0),
    },
    "sync-subscription-status": {
        "task": "apps.subscriptions.tasks.sync_subscription_status",
        "schedule": crontab(minute="*/30"),
    },
    "generate-usage-reports": {
        "task": "apps.subscriptions.tasks.generate_usage_reports",
        "schedule": crontab(day_of_month=1, hour=0, minute=0),
    },
}

Middleware de tenant: identificação por subdomínio e restrições por status

Um middleware é um componente que roda antes de a view processar a requisição, podendo anexar dados ao request e tomar decisões. No multi-tenant por subdomínio, ele identifica o tenant pelo host, valida se está ativo e então seleciona o schema correto. Esse passo é crítico para garantir que consultas do Django ocorram no schema apropriado.

Também é comum restringir acesso conforme subscription_status, permitindo apenas “active”, “trial” ou “past_due”. Isso evita que tenants cancelados sigam acessando recursos protegidos, mesmo que ainda existam dados. Nessa lógica, subdomínios reservados como “www” e “api” podem ser tratados separadamente.

O exemplo abaixo mostra um middleware customizado que lê o subdomínio e anexa o tenant ao request. Ele também retorna 404 quando o tenant não existe, evitando revelar detalhes internos.

from django.http import HttpResponseNotFound
from django_tenants.middleware import TenantMainMiddleware


class CustomTenantMiddleware(TenantMainMiddleware):
    def process_request(self, request):
        hostname = request.get_host().split(":")[0]
        parts = hostname.split(".")

        if len(parts) > 2:
            subdomain = parts[0]

            if subdomain in ["www", "api"]:
                return super().process_request(request)

            try:
                from apps.tenants.models import Tenant

                tenant = Tenant.objects.get(
                    schema_name=subdomain,
                    subscription_status__in=["active", "trial", "past_due"],
                )
                request.tenant = tenant
            except Tenant.DoesNotExist:
                return HttpResponseNotFound("Tenant não encontrado")

        return super().process_request(request)

Rate limiting: controle de abuso e estabilidade da API

Rate limiting é uma técnica para limitar o número de requisições em um intervalo, reduzindo abuso e protegendo recursos. Pode ser aplicado por usuário autenticado ou por IP quando anônimo. Uma implementação simples usa cache, como o Redis, para contar chamadas e bloquear ao exceder o limite.

Essa proteção é útil em endpoints sensíveis, como autenticação e operações de cobrança. Também ajuda a reduzir risco de negação de serviço por excesso de tráfego acidental ou malicioso. Em cenários reais, limites podem variar por plano e por tipo de endpoint.

O exemplo abaixo define um decorator com janela e máximo de requisições. Ele cria uma chave por usuário ou IP e retorna 429 ao exceder o limite.

from functools import wraps
from django.core.cache import cache
from rest_framework.response import Response
from rest_framework import status


def rate_limit(max_requests=100, window=3600):
    def decorator(func):
        @wraps(func)
        def wrapper(request, *args, **kwargs):
            if request.user.is_authenticated:
                identifier = f"usuario_{request.user.id}"
            else:
                identifier = f"ip_{request.META.get('REMOTE_ADDR')}"

            cache_key = f"rate_limit_{identifier}_{func.__name__}"
            current = cache.get(cache_key, 0)

            if current >= max_requests:
                return Response(
                    {"error": "Limite de requisições excedido"},
                    status=status.HTTP_429_TOO_MANY_REQUESTS,
                )

            cache.set(cache_key, current + 1, window)
            return func(request, *args, **kwargs)

        return wrapper

    return decorator

Estado no front-end com Zustand: usuário, assinatura e persistência

Gerenciar estado no front-end inclui armazenar dados do usuário logado e o status da assinatura, para habilitar ou restringir rotas e componentes. Zustand é uma biblioteca de estado simples e leve, e pode persistir dados no storage do navegador. Isso evita refetch excessivo e torna transições entre páginas mais rápidas.

A persistência precisa ser usada com cuidado, pois dados podem ficar desatualizados se o status mudar no Stripe. Por isso, o estado local costuma ser combinado com sincronizações periódicas, como chamar “/current” ao carregar áreas protegidas. Ainda assim, manter um snapshot do status ajuda a orientar UX e redirecionamentos iniciais.

O exemplo a seguir cria um store com usuário, assinatura e logout, com persistência em uma chave única. Ele ilustra como manter dados essenciais sem complexidade excessiva.

import { create } from "zustand";
import { persist } from "zustand/middleware";

export const useStore = create(
  persist(
    (set) => ({
      user: null,
      subscription: null,
      setUser: (user) => set({ user }),
      setSubscription: (subscription) => set({ subscription }),
      logout: () => set({ user: null, subscription: null }),
    }),
    {
      name: "app-storage",
    }
  )
);

Rotas protegidas no Next.js: autenticação e exigência de assinatura

Rotas protegidas garantem que áreas internas do app sejam acessadas apenas por usuários autenticados. Além disso, algumas áreas podem exigir assinatura ativa, bloqueando “trial expirado”, “past_due” ou “canceled”. Esse controle pode ser feito no back-end e reforçado no front-end para navegação mais consistente.

No Next.js com App Router, componentes client-side podem verificar estado e redirecionar via router. Esse padrão evita renderização de telas internas quando não há permissão. Mesmo assim, a autorização real deve estar no back-end, pois o front-end não é uma barreira de segurança definitiva.

O exemplo abaixo mostra um componente de rota protegida que valida user e, opcionalmente, assinatura ativa. Ele redireciona para login ou billing conforme o caso.

"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useStore } from "@/store/useStore";

export default function ProtectedRoute({ children, requireSubscription = false }) {
  const router = useRouter();
  const { user, subscription } = useStore();

  useEffect(() => {
    if (!user) {
      router.push("/login");
      return;
    }

    if (requireSubscription && subscription?.subscription_status !== "active") {
      router.push("/billing");
    }
  }, [user, subscription, requireSubscription, router]);

  if (!user) {
    return null;
  }

  if (requireSubscription && subscription?.subscription_status !== "active") {
    return null;
  }

  return <>{children};
}

Atualizações em tempo real com WebSockets: Django Channels e consumidores

WebSockets permitem comunicação bidirecional persistente entre cliente e servidor, útil para notificações e eventos em tempo real. No ecossistema Django, o Django Channels adiciona suporte ASGI e roteamento de conexões websocket. Isso permite que eventos, como alterações de cobrança, mensagens internas ou alertas, sejam enviados sem polling constante.

Um componente central é o consumer, que gerencia conexão, desconexão e mensagens. Um padrão comum é criar grupos por tenant e publicar notificações para todos os usuários daquele tenant. Isso melhora consistência em ambientes multi-tenant, pois cada grupo fica naturalmente segmentado.

Os exemplos a seguir mostram uma configuração ASGI que habilita websocket e um consumer que envia mensagens para um grupo por tenant. Eles representam uma base para notificações, podendo ser integrada a sinais e tarefas assíncronas.

import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack

from apps.realtime import routing

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")

application = ProtocolTypeRouter(
    {
        "http": get_asgi_application(),
        "websocket": AuthMiddlewareStack(
            URLRouter(routing.websocket_urlpatterns)
        ),
    }
)
from channels.generic.websocket import AsyncWebsocketConsumer
import json


class NotificationConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.tenant_id = self.scope["url_route"]["kwargs"]["tenant_id"]
        self.room_group_name = f"tenant_{self.tenant_id}"

        await self.channel_layer.group_add(self.room_group_name, self.channel_name)
        await self.accept()

    async def disconnect(self, close_code):
        await self.channel_layer.group_discard(self.room_group_name, self.channel_name)

    async def notification_message(self, event):
        await self.send(
            text_data=json.dumps(
                {"type": event["type"], "message": event["message"]}
            )
        )

Observabilidade: health checks, logs e monitoramento com Sentry

Observabilidade é a capacidade de entender o estado interno do sistema a partir de sinais como logs, métricas e rastreamento. Um endpoint de health check pode validar banco, cache e integração com Stripe, retornando 200 ou 503 conforme o estado. Isso ajuda balanceadores e sistemas de monitoramento a detectarem degradação.

Logs estruturados facilitam correlação e auditoria, e rotação evita que arquivos cresçam indefinidamente. Em produção, também é comum usar uma ferramenta de monitoramento de erros como Sentry, que captura exceções, contexto e rastros. Integrações com Django e Celery permitem identificar falhas em requisições e em tarefas assíncronas.

Os exemplos abaixo mostram um endpoint de health check e uma configuração de logging com saída no console e em arquivo rotativo. Eles representam uma base para diagnósticos de produção e alertas.

from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from django.db import connection
from django.core.cache import cache


@api_view(["GET"])
@permission_classes([AllowAny])
def health_check(request):
    health = {"status": "healthy", "checks": {}}

    try:
        connection.ensure_connection()
        health["checks"]["database"] = "healthy"
    except Exception as e:
        health["status"] = "unhealthy"
        health["checks"]["database"] = f"unhealthy: {str(e)}"

    try:
        cache.set("health_check", "ok", 10)
        if cache.get("health_check") == "ok":
            health["checks"]["redis"] = "healthy"
        else:
            raise Exception("Falha de leitura/escrita no cache")
    except Exception as e:
        health["status"] = "unhealthy"
        health["checks"]["redis"] = f"unhealthy: {str(e)}"

    try:
        import stripe
        stripe.Account.retrieve()
        health["checks"]["stripe"] = "healthy"
    except Exception as e:
        health["checks"]["stripe"] = f"warning: {str(e)}"

    status_code = 200 if health["status"] == "healthy" else 503
    return Response(health, status=status_code)
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "verbose": {
            "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
            "style": "{",
        },
        "json": {
            "()": "pythonjsonlogger.jsonlogger.JsonFormatter",
            "format": "%(asctime)s %(name)s %(levelname)s %(message)s",
        },
    },
    "handlers": {
        "console": {"class": "logging.StreamHandler", "formatter": "verbose"},
        "file": {
            "class": "logging.handlers.RotatingFileHandler",
            "filename": "/var/log/django/app.log",
            "maxBytes": 1024 * 1024 * 10,
            "backupCount": 10,
            "formatter": "json",
        },
    },
    "root": {"handlers": ["console", "file"], "level": "INFO"},
    "loggers": {
        "django": {"handlers": ["console", "file"], "level": "INFO", "propagate": False},
        "apps": {"handlers": ["console", "file"], "level": "INFO", "propagate": False},
    },
}

Segurança: headers, chaves de API e validação de entrada

Segurança em produção envolve camadas: proteção no proxy, no back-end e no navegador. Headers como Content-Security-Policy reduzem risco de injeção de scripts, e Strict-Transport-Security incentiva HTTPS. Um middleware central pode aplicar esses headers consistentemente em todas as respostas.

Outro ponto é a gestão de credenciais e chaves. Um modelo de API Key pode permitir integrações com terceiros, com flags de ativação e registro de uso. Essas chaves devem ser geradas com aleatoriedade forte, e a aplicação deve registrar e limitar acesso por escopo quando necessário.

Além disso, validação de entrada previne dados inválidos e comportamentos inesperados. No caso de subdomínios, uma validação evita caracteres proibidos, tamanhos inválidos e nomes reservados. O exemplo abaixo apresenta middleware de headers, um model de API key e um validador de subdomínio com expressão regular corrigida.

class SecurityHeadersMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)

        response["X-Content-Type-Options"] = "nosniff"
        response["X-Frame-Options"] = "DENY"
        response["X-XSS-Protection"] = "1; mode=block"
        response["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
        response["Content-Security-Policy"] = (
            "default-src 'self'; "
            "script-src 'self' 'unsafe-inline' https://js.stripe.com; "
            "style-src 'self' 'unsafe-inline'; "
            "img-src 'self' data: https:; "
            "font-src 'self' data:; "
            "connect-src 'self' https://api.stripe.com;"
        )

        return response
import secrets
from django.db import models
from django.contrib.auth import get_user_model


class APIKey(models.Model):
    user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
    key = models.CharField(max_length=64, unique=True)
    name = models.CharField(max_length=100)
    created_at = models.DateTimeField(auto_now_add=True)
    last_used = models.DateTimeField(null=True, blank=True)
    is_active = models.BooleanField(default=True)

    def save(self, *args, **kwargs):
        if not self.key:
            self.key = self.generate_key()
        super().save(*args, **kwargs)

    @staticmethod
    def generate_key():
        return f"sk_{secrets.token_urlsafe(32)}"

    def __str__(self):
        return f"{self.name} - {self.key[:10]}..."
import re
from django.core.exceptions import ValidationError


def validate_subdomain(value):
    if not re.match(r"^[a-z0-9-]+$", value):
        raise ValidationError(
            "Subdomínio deve conter apenas letras minúsculas, números e hífens"
        )

    if value.startswith("-") or value.endswith("-"):
        raise ValidationError("Subdomínio não pode começar ou terminar com hífen")

    if len(value) < 3 or len(value) > 63:
        raise ValidationError("Subdomínio deve ter entre 3 e 63 caracteres")

    reserved = ["www", "api", "admin", "app", "mail", "ftp"]
    if value in reserved:
        raise ValidationError(f'"{value}" é um subdomínio reservado')

Backups de banco: dump, compactação e envio para storage

Backups são a linha de defesa contra exclusão acidental, corrupção de dados e incidentes operacionais. Um comando de management do Django pode executar pg_dump, compactar o arquivo e enviar para um storage como S3. Em ambientes multi-tenant por schema, o dump pode conter todos os schemas, o que simplifica restauração completa do cluster.

O backup precisa incluir carimbo de tempo, local temporário e limpeza ao final, para evitar acúmulo no disco. Também é importante que credenciais de storage e bucket sejam variáveis de ambiente. Esse tipo de comando pode ser chamado por cron, por um job no CI ou por uma tarefa do Celery Beat.

O exemplo abaixo implementa um comando completo de backup com upload e limpeza. Ele ilustra um fluxo comum de produção, mantendo o processo reproduzível e auditável.

import os
import subprocess
from datetime import datetime

import boto3
from django.conf import settings
from django.core.management.base import BaseCommand


class Command(BaseCommand):
    help = "Backup do banco (incluindo schemas) e envio para S3"

    def handle(self, *args, **options):
        s3 = boto3.client(
            "s3",
            aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
            aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
        )

        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        backup_file = f"/tmp/backup_{timestamp}.sql"

        subprocess.run(
            [
                "pg_dump",
                "-h",
                settings.DATABASES["default"]["HOST"],
                "-U",
                settings.DATABASES["default"]["USER"],
                "-d",
                settings.DATABASES["default"]["NAME"],
                "-f",
                backup_file,
            ],
            check=True,
            env={"PGPASSWORD": settings.DATABASES["default"]["PASSWORD"]},
        )

        subprocess.run(["gzip", backup_file], check=True)
        backup_file_gz = f"{backup_file}.gz"

        s3_key = f"backups/database_{timestamp}.sql.gz"
        s3.upload_file(backup_file_gz, settings.AWS_BACKUP_BUCKET, s3_key)

        os.remove(backup_file_gz)

        self.stdout.write(self.style.SUCCESS(f"Backup concluído: {s3_key}"))

Estratégia de testes: unidade, integração e mocks do Stripe

Testes são essenciais para reduzir regressões, especialmente em cobrança e multi-tenancy. No back-end, testes podem usar TenantTestCase para criar schema e domínio de teste, e APIClient para simular chamadas autenticadas. Integrações externas como Stripe devem ser mockadas para evitar dependência de rede e custos, garantindo previsibilidade.

Além de testar o caminho feliz, é importante testar limites e proteções, como rate limiting. Esse tipo de teste garante que a API responda 429 após o número esperado de chamadas. Também ajuda a validar que middleware e tenant context estão funcionando como esperado sob carga.

O exemplo abaixo mostra um teste que simula criação de assinatura e verifica atualização do tenant. Ele também inclui um teste de rate limiting por repetição de chamadas ao endpoint.

from unittest.mock import patch, MagicMock
from django_tenants.test.cases import TenantTestCase
from rest_framework.test import APIClient

from apps.tenants.models import Tenant, Domain
from apps.users.models import User


class SubscriptionTestCase(TenantTestCase):
    def setUp(self):
        self.tenant = Tenant.objects.create(
            name="Test Company",
            slug="testco",
            schema_name="testco",
        )
        Domain.objects.create(
            domain="testco.localhost",
            tenant=self.tenant,
            is_primary=True,
        )

        self.user = User.objects.create_user(
            email="test@example.com",
            password="testpass123",
        )

        self.client = APIClient()
        self.client.force_authenticate(user=self.user)

    @patch("stripe.Subscription.create")
    def test_create_subscription(self, mock_stripe_create):
        mock_stripe_create.return_value = MagicMock(id="sub_123", status="active")

        response = self.client.post(
            "/api/subscriptions/create_subscription/",
            {"price_id": "price_123", "payment_method_id": "pm_123"},
            format="json",
        )

        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.data["status"], "active")

        self.tenant.refresh_from_db()
        self.assertEqual(self.tenant.subscription_status, "active")

    def test_rate_limiting(self):
        for i in range(101):
            response = self.client.get("/api/subscriptions/current/")
            if i < 100:
                self.assertEqual(response.status_code, 200)
            else:
                self.assertEqual(response.status_code, 429)

Encerramento: o que torna essa arquitetura realmente “pronta para produção”

Uma arquitetura SaaS com Next.js, Django, Stripe e multi-tenancy por schema reúne rapidez no front-end, robustez no back-end e cobrança confiável com eventos auditáveis. O isolamento por schema reduz risco de vazamento entre tenants e organiza crescimento por clientes, enquanto webhooks garantem que o estado interno reflita o estado real de pagamento. Ao mesmo tempo, tarefas com Celery e cache com Redis sustentam rotinas de fundo e desempenho.

O uso de Docker e CI/CD padroniza execução e entrega, reduzindo divergências de ambiente e acelerando releases com segurança. Nginx resolve roteamento por subdomínios e separação de frontend e API, e práticas como rate limiting, headers de segurança e validação de entrada reduzem superfícies de ataque. Por fim, health checks, logs e monitoramento consolidam a operação, permitindo detectar falhas rapidamente e manter a aplicação estável em produção.