---
id: saas-subscription-blueprint
domain: architecture
agents: [architect]
when: "ao construir um SaaS que cobra assinatura (do zero ao faturamento)"
---

# SaaS de assinatura — do zero ao primeiro faturamento

Blueprint extraído do `nextjs/saas-starter` (Vercel) — o starter oficial com Next.js (App Router),
Postgres, Drizzle, Stripe e shadcn/ui. Tudo aqui é **grounded no código real** do template, não em
"boas práticas" abstratas. O objetivo é dar ao arquiteto o caminho concreto: schema, fluxo de cobrança,
auth, RBAC e os pontos onde o starter falha de propósito (e você não pode copiar cego).

## O problema

"Fazer um SaaS" parece um épico, mas o caminho comercial mínimo é estreito e conhecido: **alguém se
cadastra, vira dono de um team, escolhe um plano, paga no Stripe, e o estado da assinatura volta pro seu
banco via webhook.** A maioria das tentativas erra em um de quatro lugares:

1. **Modelam billing no usuário, não no team.** Aí quando o cliente quer convidar um colega, o plano não
   acompanha — assinatura é por *conta de cobrança* (team), não por pessoa.
2. **Confiam no redirect de sucesso do Checkout pra liberar acesso.** O redirect é cosmético e burlável;
   a fonte de verdade do estado da assinatura é **o webhook**, não a URL de retorno.
3. **Reinventam o portal de billing** (trocar cartão, cancelar, fazer upgrade) — quando o Stripe Customer
   Portal já faz isso hospedado e PCI-compliant.
4. **Esquecem o RBAC.** Qualquer membro consegue remover o dono ou convidar gente. O próprio starter tem
   esse buraco — é o tell número 1 de SaaS copiado de template.

Este pack resolve os quatro.

## O conhecimento

### 1. Modelagem: a unidade de cobrança é o `team`, não o `user`

O schema real do starter (Drizzle / Postgres). Note onde os campos do Stripe moram — **em `teams`**:

```ts
// users — identidade e auth
users {
  id            serial PK
  name          varchar(100)
  email         varchar(255) NOT NULL UNIQUE
  passwordHash  text NOT NULL
  role          varchar(20) NOT NULL DEFAULT 'member'  // role GLOBAL, não de team
  createdAt     timestamp NOT NULL DEFAULT now()
  updatedAt     timestamp NOT NULL DEFAULT now()
  deletedAt     timestamp                               // soft delete
}

// teams — a CONTA DE COBRANÇA. Stripe vive aqui.
teams {
  id                    serial PK
  name                  varchar(100) NOT NULL
  createdAt             timestamp NOT NULL DEFAULT now()
  updatedAt             timestamp NOT NULL DEFAULT now()
  stripeCustomerId      text UNIQUE      // 1 customer Stripe por team
  stripeSubscriptionId  text UNIQUE
  stripeProductId       text
  planName              varchar(50)
  subscriptionStatus    varchar(20)      // espelho do status do Stripe
}

// teamMembers — a relação N:N + a role DE TEAM (esta é a que importa pro RBAC)
teamMembers {
  id        serial PK
  userId    integer NOT NULL FK -> users.id
  teamId    integer NOT NULL FK -> teams.id
  role      varchar(50) NOT NULL         // 'owner' | 'member'
  joinedAt  timestamp NOT NULL DEFAULT now()
}

// invitations — convite pendente por e-mail, aceito no signup
invitations {
  id         serial PK
  teamId     integer NOT NULL FK -> teams.id
  email      varchar(255) NOT NULL
  role       varchar(50) NOT NULL
  invitedBy  integer NOT NULL FK -> users.id
  invitedAt  timestamp NOT NULL DEFAULT now()
  status     varchar(20) NOT NULL DEFAULT 'pending'  // pending | accepted
}

// activityLogs — trilha de auditoria append-only
activityLogs {
  id         serial PK
  teamId     integer NOT NULL FK -> teams.id
  userId     integer FK -> users.id        // nullable (eventos do sistema)
  action     text NOT NULL                 // valor do enum ActivityType
  timestamp  timestamp NOT NULL DEFAULT now()
  ipAddress  varchar(45)                   // 45 = caber IPv6
}
```

**Decisão concreta a internalizar:** existem **duas roles** e elas não são a mesma coisa.
`users.role` é global (útil pra super-admin do produto). `teamMembers.role` é por team (`owner`/`member`)
— **é esta que governa quem pode convidar, remover e gerenciar billing.** Confundir as duas é como o RBAC
quebra silenciosamente.

O enum de auditoria (`ActivityType`), com os 10 eventos que o starter rastreia:

```
SIGN_UP · SIGN_IN · SIGN_OUT · UPDATE_PASSWORD · UPDATE_ACCOUNT · DELETE_ACCOUNT
CREATE_TEAM · INVITE_TEAM_MEMBER · REMOVE_TEAM_MEMBER · ACCEPT_INVITATION
```

### 2. Cobrança: Checkout (hospedado) → webhook (fonte de verdade) → Customer Portal

O fluxo tem três peças, e cada uma tem uma responsabilidade que não se mistura.

**a) `createCheckoutSession` — manda o cliente pro Checkout hospedado do Stripe.** Parâmetros reais:

```ts
stripe.checkout.sessions.create({
  mode: 'subscription',              // assinatura, não pagamento único
  payment_method_types: ['card'],
  line_items: [{ price: priceId, quantity: 1 }],
  subscription_data: { trial_period_days: 14 },  // trial sem cartão preso na hora
  allow_promotion_codes: true,       // cupom na própria tela do Stripe
  success_url: `${baseUrl}/api/stripe/checkout?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url:  `${baseUrl}/pricing`,
  // ...client_reference_id / customer = vínculo com o team
})
```
Se o usuário não está logado, o starter **redireciona pro sign-up antes** — não dá pra comprar anônimo
porque a assinatura precisa de um team pra ancorar.

**b) Webhook `POST /api/stripe/webhook` — a ÚNICA fonte de verdade do estado da assinatura.**
Verificação de assinatura obrigatória (senão qualquer um forja um POST e ganha plano premium):

```ts
const payload = await request.text();                 // RAW body, não parseado
const signature = request.headers.get('stripe-signature') as string;
let event: Stripe.Event;
try {
  event = stripe.webhooks.constructEvent(
    payload, signature, process.env.STRIPE_WEBHOOK_SECRET!
  );
} catch (err) {
  return NextResponse.json(
    { error: 'Webhook signature verification failed.' }, { status: 400 }
  );
}

switch (event.type) {
  case 'customer.subscription.updated':
  case 'customer.subscription.deleted':
    await handleSubscriptionChange(event.data.object as Stripe.Subscription);
    break;
  // outros eventos: loga e ignora (não falha)
}
return NextResponse.json({ received: true });          // 200 sempre que processou
```

`handleSubscriptionChange` traduz o status do Stripe pro seu banco:

| Status do Stripe | O que grava em `teams` |
|---|---|
| `active` ou `trialing` | preenche `stripeSubscriptionId`, `stripeProductId`, `planName`, `subscriptionStatus` |
| `canceled` ou `unpaid` | **zera** (`null`) `stripeSubscriptionId`, `stripeProductId`, `planName` |

**Por que isto importa:** o redirect de `success_url` é só UX. O acesso premium do cliente deve ser
decidido lendo `teams.subscriptionStatus` — que **só** é escrito pelo webhook. Em dev, o webhook chega via
`stripe listen --forward-to localhost:3000/api/stripe/webhook` (o CLI te dá o `whsec_...` pro
`STRIPE_WEBHOOK_SECRET`).

**c) `createCustomerPortalSession` — NÃO construa telas de billing.** O starter cria uma configuração de
portal hospedado do Stripe com:
- upgrade/downgrade de plano (troca de price, quantidade, cupom);
- atualização de método de pagamento;
- cancelamento com **motivo rastreado** (5 opções: `too_expensive`, `missing_features`,
  `switched_service`, `unused`, `other`);
- proração habilitada.

Você manda o cliente pro portal e ele volta; o webhook `customer.subscription.updated` sincroniza o
resultado. Zero tela de cartão no seu código → zero escopo PCI no seu lado.

Leitura de catálogo (pra montar a pricing page a partir do Stripe, não hardcoded):
`getStripePrices()` retorna `{ id, productId, unitAmount, currency, interval, trialPeriodDays }`;
`getStripeProducts()` retorna `{ id, name, description, defaultPriceId }`.

### 3. Auth: JWT em cookie HttpOnly + middleware que renova a sessão

Sem provider externo de auth. Sessão é um JWT assinado (`jose`, `HS256`) guardado num cookie:

```ts
// session.ts
const SALT_ROUNDS = 10;                         // bcryptjs
hashPassword(pw)        -> bcrypt hash
comparePasswords(pw, h) -> bool

signToken(payload)   -> new SignJWT(payload)
                          .setProtectedHeader({ alg: 'HS256' })
                          .setIssuedAt()
                          .setExpirationTime('1 day from now')
                          .sign(key)            // key = process.env.AUTH_SECRET
verifyToken(token)   -> jwtVerify(token, key)   // valida HS256

// cookie de sessão:
cookies().set('session', token, {
  httpOnly: true,      // JS do browser NÃO lê -> mitiga XSS roubar token
  secure: true,
  sameSite: 'lax',     // mitiga CSRF mantendo navegação top-level
  expires: <24h>,
})
```

O **middleware** protege rotas e **renova o token a cada GET** (sessão deslizante de 24h):

```ts
// middleware.ts
const protectedRoutes = '/dashboard';

const sessionCookie = request.cookies.get('session');
const isProtectedRoute = pathname.startsWith(protectedRoutes);

if (isProtectedRoute && !sessionCookie) {
  return NextResponse.redirect(new URL('/sign-in', request.url));
}

// em GET com sessão válida: re-assina com nova expiração (+24h) e regrava o cookie
// se verifyToken falhar: deleta o cookie e redireciona protegidas pro /sign-in

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
  runtime: 'nodejs',   // jose precisa de Node, não Edge runtime
};
```

**Decisão concreta:** o middleware faz só o *gate grosso* (tem cookie? rota protegida?). A autorização
fina (este usuário pode fazer *esta* ação?) **não** mora no middleware — mora na Server Action. Misturar os
dois é o erro clássico.

### 4. Server Actions: validação + autenticação como wrappers compostos

O starter encapsula o boilerplate de toda action em três wrappers. Use sempre o mais restritivo que serve:

```ts
type ActionState = { error?: string; success?: string; [key: string]: any };

// 1. valida FormData contra um schema zod
validatedAction(schema, async (data, formData) => { ... })

// 2. valida + EXIGE usuário autenticado (injeta `user`)
validatedActionWithUser(schema, async (data, formData, user) => { ... })

// 3. exige user + carrega o team (injeta `team`, redireciona se não logado)
withTeam(async (formData, team) => { ... })
```

Padrão de validação real (`zod` + `safeParse`, devolve a 1ª mensagem de erro):

```ts
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) return { error: result.error.errors[0].message };
```

### 5. O buraco de RBAC que você NÃO pode copiar

Auditando as actions reais: `removeTeamMember` e `inviteTeamMember` usam `validatedActionWithUser` e
checam **apenas se o caller pertence a um team** — **não checam se ele é `owner`.** Isso significa que,
no template como está, **qualquer `member` pode remover qualquer pessoa (inclusive o dono) ou convidar
gente.** É um tell de SaaS feito de template cru.

O fix concreto que o arquiteto deve impor antes de qualquer mutação sensível:

```ts
// dentro da action, depois de carregar a membership do caller:
const callerMembership = await getTeamMembership(user.id, teamId);
if (callerMembership?.role !== 'owner') {
  return { error: 'Apenas o dono do time pode gerenciar membros.' };
}
```

E toda ação sensível (convidar, remover, mudar plano) deve gravar em `activityLogs` via `logActivity`:

```ts
async function logActivity(teamId, userId, type: ActivityType, ipAddress?) {
  if (teamId == null) return;
  await db.insert(activityLogs).values({
    teamId, userId, action: type, ipAddress: ipAddress ?? '',
  });
}
```

### 6. O fluxo de convite (signup que aceita invite)

Convite é por e-mail com `status: 'pending'`. No signup, se vier `inviteId`, a action procura o convite
**casando id + email + status pending**, e só então herda a role do convite e marca `accepted`:

```ts
const [invitation] = await db.select().from(invitations).where(and(
  eq(invitations.id, parseInt(inviteId)),
  eq(invitations.email, email),
  eq(invitations.status, 'pending'),
)).limit(1);

if (invitation) {
  userRole = invitation.role;                          // herda role do convite
  await db.update(invitations).set({ status: 'accepted' })
    .where(eq(invitations.id, invitation.id));
} else {
  // sem convite válido -> cria team novo e vira 'owner'
}
```
Sem convite válido, o usuário vira `owner` de um team novo. O casamento por e-mail impede aceitar convite
de outra pessoa.

## Checklist

- [ ] Os campos `stripeCustomerId / stripeSubscriptionId / planName / subscriptionStatus` estão em
      **`teams`**, não em `users`?
- [ ] O acesso premium é decidido lendo `teams.subscriptionStatus` (escrito pelo webhook) e **não** pelo
      redirect de sucesso do Checkout?
- [ ] O webhook verifica a assinatura com `stripe.webhooks.constructEvent` usando o **raw body** e
      `STRIPE_WEBHOOK_SECRET`, retornando 400 se falhar?
- [ ] O webhook trata `customer.subscription.updated` **e** `customer.subscription.deleted`, e
      **zera** os campos no `canceled`/`unpaid`?
- [ ] Trocar cartão / cancelar / upgrade vão pro **Customer Portal** hospedado (zero tela de cartão no
      seu código)?
- [ ] O cookie de sessão é `httpOnly: true`, `secure: true`, `sameSite: 'lax'`, e o JWT é `HS256` com
      `AUTH_SECRET`?
- [ ] O middleware renova o token nos GETs (sessão deslizante) e usa `runtime: 'nodejs'`?
- [ ] A autorização **fina** mora na Server Action (não no middleware), via wrappers
      `validatedActionWithUser` / `withTeam`?
- [ ] Convidar / remover / mudar plano checam `teamMembers.role === 'owner'` **antes** de mutar?
      (o starter NÃO faz — é o buraco a fechar)
- [ ] Toda ação sensível grava `activityLogs` com `ActivityType` e IP?
- [ ] Convite casa **id + email + status pending** antes de herdar a role?
- [ ] Em dev, o webhook chega via `stripe listen --forward-to .../api/stripe/webhook` com `whsec_`?

## Tabela de decisão

| Você precisa de... | Faça assim (grounded no starter) | Não faça |
|---|---|---|
| Unidade de cobrança | Campos Stripe em `teams`; 1 `stripeCustomerId` por team | Billing por usuário individual |
| Liberar acesso premium | Ler `teams.subscriptionStatus` (sync do webhook) | Confiar no `success_url` do Checkout |
| Coletar pagamento | Stripe Checkout hospedado (`mode: 'subscription'`) | Formulário de cartão próprio (vira escopo PCI) |
| Trocar cartão / cancelar / upgrade | Stripe Customer Portal hospedado | Construir telas de billing do zero |
| Saber o estado real da assinatura | Webhook com `constructEvent` + `STRIPE_WEBHOOK_SECRET` | Polling da API ou estado no cliente |
| Trial | `subscription_data: { trial_period_days: 14 }` | Lógica de trial própria no seu banco |
| Sessão | JWT `HS256` (jose) em cookie `httpOnly`+`secure`+`sameSite:lax` | Token em `localStorage` (XSS lê) |
| Gate de rota | Middleware: tem cookie? rota protegida? renova no GET | Checar permissão fina no middleware |
| Permissão fina (quem pode o quê) | Na Server Action, cheque `teamMembers.role === 'owner'` | Assumir que membership == permissão |
| Validar input de action | `validatedAction*` + `zod.safeParse(Object.fromEntries(formData))` | Validação manual espalhada |
| Auditoria | `logActivity` append-only em `activityLogs` com `ActivityType` | Sem trilha (impossível investigar abuso) |
| Convite de membro | `invitations` por e-mail, `pending` → `accepted` casando id+email | Adicionar membro direto sem aceite |
| Runtime do middleware | `runtime: 'nodejs'` (jose não roda em Edge) | Edge runtime com `jose`/crypto Node |

---

**Fonte:** [`nextjs/saas-starter`](https://github.com/nextjs/saas-starter) (Vercel) — Next.js App Router,
Postgres, Drizzle, Stripe, shadcn/ui. Schema, fluxo de billing, auth, middleware e actions extraídos do
código real do template. O buraco de RBAC em `removeTeamMember`/`inviteTeamMember` é uma característica
conhecida do starter — está aqui de propósito, como armadilha a corrigir.
