Calcmed Partners: Requisitos e Arquitetura Técnica

Sistema de Gestão de Parceiros e Cupons com Integração Stripe

Documento prescritivo e único de referência para implementação. Versionar como docs/ARCHITECTURE.md.


1. Visão Geral

1.1 Resumo Executivo

Plataforma fullstack Next.js que permite a uma organização gerenciar parceiros comerciais e seus cupons de desconto sincronizados com Stripe, registrando o uso real desses cupons em transações reais e expondo dashboards de performance individualizados (por parceiro) e consolidados (admin). O sistema é a fonte de verdade local de uso e receita atribuída, e o Stripe é a fonte de verdade dos cupons e das transações financeiras — reconciliados via webhooks e cron.

1.2 Personas e Fluxos Principais

Persona Objetivo Fluxo nuclear
Admin Operar a base de parceiros e a economia de cupons Login → cria parceiro → cria cupom (gera no Stripe) → associa cupom ao parceiro → acompanha analytics global
Parceiro Acompanhar a performance dos próprios cupons Login → dashboard pessoal → filtra período/cupom → exporta extrato
Stripe (sistema externo) Emite eventos financeiros reais Webhook → registra coupon_usage + transaction → atualiza dashboards

1.3 Arquitetura em Alto Nível

[Diagram]

2. Stack Técnica Detalhada

2.1 Núcleo

Tecnologia Versão Justificativa
Next.js 15.x (App Router) Server Components, Server Actions, streaming, RSC payload. Padrão atual da Vercel.
React 19.x useActionState, useOptimistic, suporte nativo a Actions.
TypeScript 5.6+ strict: true obrigatório. Sem any.
Node runtime 20.x Runtime padrão Vercel para rotas server. Webhook Stripe exige runtime Node (não Edge).
PostgreSQL 16 (Neon) Branching, autoscale, integração nativa Vercel.
Stripe Node SDK stripe 17.x SDK oficial, tipos atualizados.

2.2 Bibliotecas Auxiliares

Camada Biblioteca Justificativa
ORM Drizzle ORM + drizzle-kit SQL-first, type-safety por inferência sem codegen runtime, bundle pequeno, performático em serverless, migrations via SQL.
Driver Postgres @neondatabase/serverless HTTP-fetch driver compatível com Edge e cold starts da Vercel.
Validação Zod 3.x Schemas compartilhados client/server, inferência de tipos.
Auth Auth.js v5 (NextAuth) Ver §2.3.
Hash de senha @node-rs/bcrypt Rust-based, rápido em ambiente serverless.
UI shadcn/ui + Tailwind CSS 4 Componentes copiáveis, ownership do código, tematização via CSS vars.
Tabelas TanStack Table v8 Headless, ordenação/filtros/paginação tipados.
Gráficos Recharts Maduro, declarativo, integra com shadcn/ui (componente Chart).
Datas/timezone date-fns + date-fns-tz Tree-shakeable; timezone explícito (ver §8.4).
Email Resend + React Email DX excelente, templates JSX, free tier generoso.
CSV papaparse (export client) ou stream Node csv-stringify (server) Export de relatórios.
Logs estruturados Pino + Vercel Logs JSON estruturado, baixo overhead.
Erros Sentry (@sentry/nextjs) Tracing distribuído server+client, source maps automáticos na Vercel.
Rate limit @upstash/ratelimit + Upstash Redis Free tier; protege login e webhooks.
Idempotência Tabela webhook_events (ver §3) Sem dependência externa para deduplicação.
Testes Vitest + Playwright Vitest para unit/integration, Playwright para E2E.

2.3 Decisão de Autenticação

Decisão: Auth.js v5 (NextAuth) com adapter Drizzle, provider Credentials, sessão JWT em cookie httpOnly.

Justificativa técnica:

  1. Custo zero — biblioteca open-source, sem SaaS.
  2. Integração nativa com App Routerauth() helper funciona em Server Components, Server Actions, Route Handlers e middleware.
  3. Adapter oficial Drizzle — sem necessidade de manter código de persistência de sessão.
  4. JWT em cookie httpOnly + SameSite=Lax — sem round-trip ao banco em cada request (sessões database adicionam latência em serverless).
  5. Recuperação de senha — implementada manualmente sobre o adapter (tabela password_reset_tokens), pois o provider Credentials não cobre esse fluxo nativamente. É uma feature isolada, simples e idiomática.
  6. DX superior a Lucia — Lucia foi descontinuada como biblioteca em 2025 (virou guia). Better Auth ainda é jovem demais para sistema de produção financeiro. Auth.js v5 é estável, documentado e amplamente adotado.

Trade-off aceito: Auth.js tem mais "magia" que uma solução própria com JWT puro, mas o ganho em manutenção, integração e callbacks tipados compensa.


3. Modelo de Dados (PostgreSQL)

3.1 Diagrama ER

[Diagram]

3.2 DDL Completo

Convenções: UUID v7 (gen_random_uuid() enquanto v7 não estiver no core; usar extensão pg_uuidv7 se disponível no Neon), snake_case, created_at/updated_at em todas as entidades de domínio, timestamps em timestamptz.

-- Extensions
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "citext";

-- ============================================================
-- USERS (Auth.js + domínio)
-- ============================================================
CREATE TABLE users (
    id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    email           citext NOT NULL UNIQUE,
    password_hash   text,
    name            text NOT NULL,
    role            text NOT NULL CHECK (role IN ('admin', 'partner')),
    email_verified  timestamptz,
    created_at      timestamptz NOT NULL DEFAULT now(),
    updated_at      timestamptz NOT NULL DEFAULT now(),
    deactivated_at  timestamptz
);
CREATE INDEX idx_users_role             ON users(role) WHERE deactivated_at IS NULL;
CREATE INDEX idx_users_deactivated_at   ON users(deactivated_at);

-- ============================================================
-- PARTNERS
-- ============================================================
CREATE TABLE partners (
    id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         uuid NOT NULL UNIQUE REFERENCES users(id) ON DELETE RESTRICT,
    company_name    text NOT NULL,
    contact_phone   text,
    notes           text,
    created_at      timestamptz NOT NULL DEFAULT now(),
    updated_at      timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_partners_company_name  ON partners USING gin (company_name gin_trgm_ops);

-- ============================================================
-- COUPONS
-- ============================================================
CREATE TABLE coupons (
    id                  uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    stripe_coupon_id    text NOT NULL UNIQUE,
    partner_id          uuid REFERENCES partners(id) ON DELETE SET NULL,
    code                citext NOT NULL UNIQUE,
    name                text NOT NULL,
    discount_type       text NOT NULL CHECK (discount_type IN ('percent', 'amount')),
    discount_value      integer NOT NULL CHECK (discount_value > 0),
    currency            text CHECK (currency ~ '^[a-z]{3}
    
), max_redemptions integer CHECK (max_redemptions IS NULL OR max_redemptions > 0), valid_from timestamptz, valid_until timestamptz, active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT chk_amount_requires_currency CHECK (discount_type = 'percent' OR currency IS NOT NULL), CONSTRAINT chk_validity_range CHECK (valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from) ); CREATE INDEX idx_coupons_partner_id ON coupons(partner_id); CREATE INDEX idx_coupons_active ON coupons(active) WHERE active = true; CREATE INDEX idx_coupons_valid_until ON coupons(valid_until); -- ============================================================ -- TRANSACTIONS -- ============================================================ CREATE TABLE transactions ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), stripe_payment_intent_id text UNIQUE, stripe_checkout_session_id text UNIQUE, stripe_invoice_id text UNIQUE, customer_email citext, amount_total integer NOT NULL, amount_discount integer NOT NULL DEFAULT 0, currency text NOT NULL CHECK (currency ~ '^[a-z]{3}
), status text NOT NULL, paid_at timestamptz, raw jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_transactions_paid_at ON transactions(paid_at DESC); CREATE INDEX idx_transactions_status ON transactions(status); CREATE INDEX idx_transactions_customer ON transactions(customer_email); -- ============================================================ -- COUPON_USAGES (tabela-fato de analytics) -- ============================================================ CREATE TABLE coupon_usages ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), coupon_id uuid NOT NULL REFERENCES coupons(id) ON DELETE RESTRICT, transaction_id uuid NOT NULL UNIQUE REFERENCES transactions(id) ON DELETE RESTRICT, stripe_event_id text NOT NULL UNIQUE, redeemed_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_coupon_usages_coupon_redeemed ON coupon_usages(coupon_id, redeemed_at DESC); CREATE INDEX idx_coupon_usages_redeemed_at ON coupon_usages(redeemed_at DESC); -- ============================================================ -- AUDIT LOGS -- ============================================================ CREATE TABLE audit_logs ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), actor_id uuid REFERENCES users(id) ON DELETE SET NULL, action text NOT NULL, entity text NOT NULL, entity_id uuid, diff jsonb, ip inet, user_agent text, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_audit_logs_actor_created ON audit_logs(actor_id, created_at DESC); CREATE INDEX idx_audit_logs_entity ON audit_logs(entity, entity_id); -- ============================================================ -- WEBHOOK EVENTS (idempotência) -- ============================================================ CREATE TABLE webhook_events ( stripe_event_id text PRIMARY KEY, type text NOT NULL, received_at timestamptz NOT NULL DEFAULT now(), processed_at timestamptz, status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','processed','failed','skipped')), error text ); CREATE INDEX idx_webhook_events_status ON webhook_events(status, received_at); -- ============================================================ -- PASSWORD RESET TOKENS -- ============================================================ CREATE TABLE password_reset_tokens ( token_hash text PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at timestamptz NOT NULL, used_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_password_reset_user ON password_reset_tokens(user_id); -- ============================================================ -- AUTH.JS adapter tables (omitidas — geradas pelo adapter Drizzle: -- accounts, sessions, verification_tokens, authenticators) -- ============================================================ -- Trigger universal de updated_at CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS ```math-display [Formula] ``` LANGUAGE plpgsql; CREATE TRIGGER trg_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE TRIGGER trg_partners_updated_at BEFORE UPDATE ON partners FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE TRIGGER trg_coupons_updated_at BEFORE UPDATE ON coupons FOR EACH ROW EXECUTE FUNCTION set_updated_at();

3.3 Justificativa de Cada Índice

Índice Motivo
idx_users_role (partial) Filtro frequente "listar parceiros ativos" / "listar admins".
idx_users_deactivated_at Suporte ao soft delete; queries de auditoria.
idx_partners_company_name (gin trigram) Busca textual no admin (ILIKE %term%).
idx_coupons_partner_id Listagem de cupons por parceiro (dashboard do parceiro).
idx_coupons_active (partial) Listagens default filtram por ativos.
idx_coupons_valid_until Job cron expira cupons; index range scan.
idx_transactions_paid_at DESC Ordenação default de extrato.
idx_transactions_status Filtros operacionais (pagos, falhos, reembolsados).
idx_coupon_usages_coupon_redeemed (composto) Query crítica: "usos do cupom X no período Y". Cobertura por covering index.
idx_coupon_usages_redeemed_at Agregações temporais globais (admin).
idx_audit_logs_actor_created Ver histórico de ações de um admin.
idx_audit_logs_entity Ver histórico de uma entidade específica.
idx_webhook_events_status Cron de retry busca status='failed' recentes.

3.4 Estratégia Delete

Entidade Estratégia Razão
users Soft delete (deactivated_at) Manter integridade de FKs em audit_logs, coupons, coupon_usages.
partners Soft delete via users.deactivated_at Parceiro é 1:1 com user.
coupons Hard delete proibido; usar active=false Histórico financeiro depende.
coupon_usages Imutável. Sem delete. Tabela-fato.
transactions Imutável. Sem delete. Tabela-fato.
audit_logs Imutável. Retenção configurável via cron. Conformidade.
webhook_events TTL 90 dias via cron Apenas idempotência recente importa.
password_reset_tokens TTL imediato após uso/expiração Segurança.

3.5 Drizzle vs Prisma — Decisão

Decisão: Drizzle.

Por quê: Prisma em serverless ainda tem overhead de inicialização do Query Engine (binário Rust), enquanto Drizzle é puro TypeScript, bundle ~7kb, sem geração de cliente em runtime. Drizzle alinha melhor com o driver @neondatabase/serverless em HTTP fetch. Migrations SQL-first via drizzle-kit evitam o lock-in do schema declarativo proprietário.

Exemplo de schema equivalente (lib/db/schema.ts):

import { pgTable, uuid, text, timestamp, integer, boolean, jsonb, inet } from 'drizzle-orm/pg-core';
import { sql, relations } from 'drizzle-orm';

export const users = pgTable('users', {
  id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
  email: text('email').notNull().unique(),
  passwordHash: text('password_hash'),
  name: text('name').notNull(),
  role: text('role', { enum: ['admin', 'partner'] }).notNull(),
  emailVerified: timestamp('email_verified', { withTimezone: true }),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  deactivatedAt: timestamp('deactivated_at', { withTimezone: true }),
});

export const partners = pgTable('partners', {
  id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
  userId: uuid('user_id').notNull().unique().references(() => users.id, { onDelete: 'restrict' }),
  companyName: text('company_name').notNull(),
  contactPhone: text('contact_phone'),
  notes: text('notes'),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

export const coupons = pgTable('coupons', {
  id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
  stripeCouponId: text('stripe_coupon_id').notNull().unique(),
  partnerId: uuid('partner_id').references(() => partners.id, { onDelete: 'set null' }),
  code: text('code').notNull().unique(),
  name: text('name').notNull(),
  discountType: text('discount_type', { enum: ['percent', 'amount'] }).notNull(),
  discountValue: integer('discount_value').notNull(),
  currency: text('currency'),
  maxRedemptions: integer('max_redemptions'),
  validFrom: timestamp('valid_from', { withTimezone: true }),
  validUntil: timestamp('valid_until', { withTimezone: true }),
  active: boolean('active').notNull().default(true),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

export const partnersRelations = relations(partners, ({ one, many }) => ({
  user: one(users, { fields: [partners.userId], references: [users.id] }),
  coupons: many(coupons),
}));
// ... análogo para demais tabelas

4. Arquitetura de Pastas (App Router)

.
├── app/
│   ├── (auth)/
│   │   ├── login/page.tsx
│   │   ├── forgot-password/page.tsx
│   │   ├── reset-password/[token]/page.tsx
│   │   └── layout.tsx
│   ├── (admin)/
│   │   ├── layout.tsx                    # guard requireRole('admin')
│   │   ├── dashboard/page.tsx
│   │   ├── partners/
│   │   │   ├── page.tsx                  # listagem
│   │   │   ├── new/page.tsx
│   │   │   └── [id]/
│   │   │       ├── page.tsx              # detalhe + dashboard do parceiro
│   │   │       └── edit/page.tsx
│   │   ├── coupons/
│   │   │   ├── page.tsx
│   │   │   ├── new/page.tsx
│   │   │   └── [id]/edit/page.tsx
│   │   ├── analytics/page.tsx
│   │   └── audit-logs/page.tsx
│   ├── (partner)/
│   │   ├── layout.tsx                    # guard requireRole('partner')
│   │   ├── dashboard/page.tsx
│   │   ├── coupons/page.tsx
│   │   └── transactions/page.tsx
│   ├── (public)/
│   │   └── page.tsx                      # landing/redirect
│   ├── api/
│   │   ├── auth/[...nextauth]/route.ts
│   │   ├── stripe/
│   │   │   └── webhook/route.ts
│   │   ├── cron/
│   │   │   └── reconcile-stripe/route.ts
│   │   └── export/
│   │       └── csv/route.ts
│   ├── layout.tsx
│   ├── error.tsx
│   ├── not-found.tsx
│   └── globals.css
├── middleware.ts
├── auth.ts                               # config Auth.js
├── auth.config.ts                        # config edge-compatible
├── components/
│   ├── ui/                               # shadcn primitives
│   ├── charts/                           # wrappers Recharts
│   ├── tables/                           # TanStack Table wrappers
│   ├── forms/                            # form fields tipados
│   └── layout/                           # nav, sidebar, header
├── server/
│   ├── actions/
│   │   ├── partners.ts
│   │   ├── coupons.ts
│   │   ├── auth.ts
│   │   └── exports.ts
│   ├── services/
│   │   ├── stripe.ts                     # wrapper SDK + retries
│   │   ├── email.ts                      # Resend wrapper
│   │   ├── audit.ts                      # logAudit()
│   │   └── reconciliation.ts
│   └── auth/
│       ├── session.ts                    # getSession, requireRole
│       └── password.ts                   # hash, compare, reset-flow
├── lib/
│   ├── db/
│   │   ├── client.ts                     # drizzle instance
│   │   ├── schema.ts                     # tabelas Drizzle
│   │   └── queries/
│   │       ├── partners.ts
│   │       ├── coupons.ts
│   │       ├── analytics.ts
│   │       └── audit.ts
│   ├── validation/
│   │   ├── partner.ts                    # zod schemas
│   │   ├── coupon.ts
│   │   └── shared.ts
│   ├── utils/
│   │   ├── currency.ts                   # cents <-> brl
│   │   ├── dates.ts                      # tz helpers
│   │   └── csv.ts
│   ├── stripe/
│   │   └── client.ts
│   ├── env.ts                            # validação zod das envs
│   └── result.ts                         # Result<T,E>
├── drizzle/
│   ├── migrations/
│   └── meta/
├── emails/                               # React Email templates
│   ├── invite.tsx
│   └── reset-password.tsx
├── tests/
│   ├── unit/
│   └── e2e/
├── public/
├── drizzle.config.ts
├── next.config.ts
├── tsconfig.json
└── package.json

4.1 Convenções de Separação

Camada Regra
app/**/page.tsx Server Component por padrão. Marcar 'use client' apenas em folhas interativas.
components/ui/ Componentes shadcn (client).
components/forms/, components/charts/ Client Components nomeados com sufixo .client.tsx opcional.
server/actions/ 'use server' no topo. Toda action recebe input validado por Zod.
server/services/ Lógica de domínio reutilizável; nunca importada por Client Components.
lib/db/queries/ Funções puras que executam SQL via Drizzle; retornam tipos inferidos.
lib/validation/ Schemas Zod compartilhados entre forms e actions.

5. Rotas e Endpoints

5.1 Páginas (App Router)

Rota Acesso Descrição Server/Client
/ público Redirect para /login ou dashboard correto Server
/login público Form de login Server (form client)
/forgot-password público Form de solicitação de reset Server
/reset-password/[token] público Form de nova senha Server
/dashboard (admin) admin KPIs globais Server
/partners admin Listagem com filtros Server
/partners/new admin Criar parceiro Server (form client)
/partners/[id] admin Dashboard individual + ações Server
/partners/[id]/edit admin Editar parceiro Server
/coupons admin Listagem de cupons Server
/coupons/new admin Criar cupom Server
/coupons/[id]/edit admin Editar cupom Server
/analytics admin Gráficos consolidados + filtros + export Server
/audit-logs admin Trilha de auditoria Server
/dashboard (partner) partner KPIs próprios Server
/coupons (partner) partner Cupons próprios (read-only) Server
/transactions (partner) partner Extrato de transações Server

Route groups (admin) e (partner) compartilham segmentos como /dashboard e /coupons — Auth.js + middleware resolvem o destino correto.

5.2 API Routes / Route Handlers

Método Endpoint Acesso Payload Response Descrição
GET/POST /api/auth/[...nextauth] público Auth.js Endpoints Auth.js.
POST /api/stripe/webhook Stripe (sig) Stripe.Event raw 200 {received:true} Webhook único Stripe.
GET /api/cron/reconcile-stripe Vercel Cron header Authorization: Bearer <CRON_SECRET> {ok:true, stats} Reconciliação periódica.
GET /api/export/csv admin/partner (scope-aware) query ?type=usages&from=&to=&couponId= text/csv stream Export filtrado.

Regras gerais:

5.3 Server Actions

// server/actions/partners.ts
'use server';

export async function createPartner(
  input: z.infer<typeof createPartnerSchema>
): Promise<Result<{ partnerId: string }, ActionError>>;

export async function updatePartner(
  id: string,
  input: z.infer<typeof updatePartnerSchema>
): Promise<Result<void, ActionError>>;

export async function deactivatePartner(
  id: string
): Promise<Result<void, ActionError>>;

export async function resendPartnerInvite(
  id: string
): Promise<Result<void, ActionError>>;

// server/actions/coupons.ts
export async function createCoupon(
  input: z.infer<typeof createCouponSchema>
): Promise<Result<{ couponId: string; stripeCouponId: string }, ActionError>>;

export async function updateCoupon(
  id: string,
  input: z.infer<typeof updateCouponSchema>
): Promise<Result<void, ActionError>>;

export async function toggleCouponActive(
  id: string,
  active: boolean
): Promise<Result<void, ActionError>>;

export async function assignCouponToPartner(
  couponId: string,
  partnerId: string | null
): Promise<Result<void, ActionError>>;

// server/actions/auth.ts
export async function requestPasswordReset(
  email: string
): Promise<Result<void, ActionError>>;

export async function performPasswordReset(
  token: string,
  newPassword: string
): Promise<Result<void, ActionError>>;

// server/actions/exports.ts
export async function exportAnalyticsCsv(
  filters: AnalyticsFilters
): Promise<Result<{ url: string }, ActionError>>;

Regras de toda Server Action:

  1. Primeiro statement: const session = await requireRole('admin' | 'partner').
  2. Segundo: const parsed = schema.safeParse(input) → retornar Err('VALIDATION', ...).
  3. Operação dentro de transação quando há side-effect Stripe + DB.
  4. Toda action que modifica entidade chama logAudit({ actor, action, entity, entityId, diff }).
  5. Toda action revalida tags relevantes ao final (revalidateTag('partners')).

5.4 Webhooks Stripe

Endpoint único: POST /api/stripe/webhook

Eventos tratados:

Evento Ação
coupon.created Upsert em coupons por stripe_coupon_id (apenas se inexistente — origem normal é admin).
coupon.updated Atualizar campos sincronizáveis (validade, max_redemptions, active via valid).
coupon.deleted active=false no cupom; não deletar registro.
checkout.session.completed Se total_details.breakdown.discounts contém cupom rastreado → inserir transactions + coupon_usages.
invoice.paid Idem checkout, para assinaturas/faturas recorrentes.
charge.refunded Marcar transação como refunded; não remover coupon_usages (analytics mantém histórico + flag).

Estratégia de verificação e idempotência:

  1. Ler req.text() cru.
  2. stripe.webhooks.constructEvent(raw, signature, WEBHOOK_SECRET).
  3. Em transação: a. INSERT INTO webhook_events ... ON CONFLICT (stripe_event_id) DO NOTHING RETURNING *. Se nada retornar → já processado, responder 200. b. Dispatcher por event.type. c. UPDATE webhook_events SET status='processed', processed_at=now().
  4. Em erro: UPDATE webhook_events SET status='failed', error=...; responder 500 para Stripe reentregar.
  5. Todo INSERT INTO coupon_usages usa event.id como stripe_event_id UK — segunda camada de idempotência.

6. Autenticação e Autorização

6.1 Fluxos

Login:

[Diagram]

Recuperação de senha:

[Diagram]

Logout: Auth.js signOut() → limpa cookie.

6.2 Middleware

// middleware.ts
import { auth } from '@/auth.config';

export default auth((req) => {
  const { nextUrl, auth: session } = req;
  const isAuth = !!session?.user;
  const role = session?.user?.role;

  const isPublic = nextUrl.pathname.startsWith('/login') ||
                   nextUrl.pathname.startsWith('/forgot-password') ||
                   nextUrl.pathname.startsWith('/reset-password');

  if (isPublic) {
    if (isAuth) return Response.redirect(new URL(role === 'admin' ? '/dashboard' : '/dashboard', nextUrl));
    return;
  }

  if (!isAuth) return Response.redirect(new URL('/login', nextUrl));

  const adminOnly = ['/partners', '/coupons', '/analytics', '/audit-logs'];
  if (role === 'partner' && adminOnly.some((p) => nextUrl.pathname.startsWith(p))) {
    return Response.redirect(new URL('/dashboard', nextUrl));
  }
});

export const config = {
  matcher: ['/((?!api/stripe/webhook|api/cron|_next/static|_next/image|favicon.ico|.*\\.).*)'],
};

Webhook e cron excluídos do matcher — usam autenticação própria (signature / bearer).

6.3 RBAC e Helpers

// server/auth/session.ts
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export type Role = 'admin' | 'partner';

export async function getSession() {
  return auth();
}

export async function requireSession() {
  const session = await auth();
  if (!session?.user) redirect('/login');
  return session;
}

export async function requireRole(role: Role | Role[]) {
  const session = await requireSession();
  const roles = Array.isArray(role) ? role : [role];
  if (!roles.includes(session.user.role)) redirect('/');
  return session;
}

export async function requirePartnerScope(partnerId: string) {
  const session = await requireRole(['admin', 'partner']);
  if (session.user.role === 'partner' && session.user.partnerId !== partnerId) {
    redirect('/dashboard');
  }
  return session;
}

Toda Server Action ou Server Component que toque dados sensíveis começa com uma dessas três chamadas — sem exceção.


7. Integração Stripe

7.1 Eventos de Webhook

Lista canônica (idêntica à §5.4) configurada no Stripe Dashboard apontando para https://<domain>/api/stripe/webhook:

7.2 Source-of-Truth de Cupons

Decisão: Admin é source-of-truth da existência e associação; Stripe é source-of-truth do estado financeiro.

Fluxo de criação:

  1. Admin submete form → Server Action createCoupon.
  2. Action chama stripe.coupons.create({ id: <code>, ... }) com idempotencyKey: nanoid().
  3. Após sucesso Stripe → INSERT em coupons com stripe_coupon_id.
  4. Em transação: INSERT INTO audit_logs.
  5. Se falhar Stripe → action retorna erro, nada gravado localmente.
  6. Se falhar local após Stripe OK → próxima reconciliação (§7.3) reimporta.

Edição: mesmos passos; campos imutáveis no Stripe (amount_off, percent_off) são read-only no form de edição.

Desativação: local seta active=false + chama stripe.coupons.del() (Stripe não permite "pause", apenas delete; semanticamente equivale).

7.3 Reconciliação Periódica

Vercel Cron em vercel.json:

{
  "crons": [
    { "path": "/api/cron/reconcile-stripe", "schedule": "0 */6 * * *" },
    { "path": "/api/cron/expire-coupons", "schedule": "5 0 * * *" }
  ]
}

Handler de reconciliação:

  1. Autentica via header Authorization: Bearer ${process.env.CRON_SECRET}.
  2. Lista stripe.coupons.list({ limit: 100 }) paginando.
  3. Para cada cupom Stripe: compara com local — se inexistente ou divergente, registra em audit_logs com action reconciliation_drift.
  4. Lista stripe.events.list({ types: [...], created: { gte: lastRun } }) para recuperar eventos perdidos.
  5. Para cada evento não presente em webhook_events: invoca o mesmo dispatcher do webhook.

7.4 Idempotência e Retries

Operação Mecanismo
Outbound (Server → Stripe) idempotencyKey em toda mutation (UUID gerado por action).
Inbound (Stripe → Server) Tabela webhook_events (PK stripe_event_id).
Inserts em coupon_usages UK em stripe_event_id.
Retries Stripe Aceitar — handler é idempotente. Responder 200 se evento já processado.

8. Camada de Dados e Queries

8.1 Padrão Organizacional

lib/db/queries/<entidade>.ts — funções puras, async, tipadas, sem side-effect além de leitura. Mutações ficam em server/actions/.

lib/db/queries/
├── partners.ts       # listPartners, getPartnerById, getPartnerByUserId
├── coupons.ts        # listCoupons, getCouponById, listCouponsByPartner
├── analytics.ts      # revenueByPeriod, topPartners, usageTimeline
├── transactions.ts
└── audit.ts

8.2 Exemplo de Query Agregada

// lib/db/queries/analytics.ts
import { db } from '@/lib/db/client';
import { couponUsages, transactions, coupons, partners } from '@/lib/db/schema';
import { and, eq, gte, lt, sql, desc } from 'drizzle-orm';

export interface PartnerRevenueRow {
  partnerId: string;
  partnerName: string;
  usageCount: number;
  grossRevenueCents: number;
  netRevenueCents: number;
  avgTicketCents: number;
}

export async function revenueByPartner(params: {
  from: Date;
  to: Date;
  currency: string;
  limit?: number;
}): Promise<PartnerRevenueRow[]> {
  return db
    .select({
      partnerId: partners.id,
      partnerName: partners.companyName,
      usageCount: sql<number>`count(*)::int`,
      grossRevenueCents: sql<number>`coalesce(sum(${transactions.amountTotal}), 0)::int`,
      netRevenueCents: sql<number>`coalesce(sum(${transactions.amountTotal} - ${transactions.amountDiscount}), 0)::int`,
      avgTicketCents: sql<number>`coalesce(avg(${transactions.amountTotal}), 0)::int`,
    })
    .from(couponUsages)
    .innerJoin(coupons, eq(coupons.id, couponUsages.couponId))
    .innerJoin(partners, eq(partners.id, coupons.partnerId))
    .innerJoin(transactions, eq(transactions.id, couponUsages.transactionId))
    .where(
      and(
        gte(couponUsages.redeemedAt, params.from),
        lt(couponUsages.redeemedAt, params.to),
        eq(transactions.currency, params.currency),
        eq(transactions.status, 'paid'),
      ),
    )
    .groupBy(partners.id, partners.companyName)
    .orderBy(desc(sql`sum(${transactions.amountTotal})`))
    .limit(params.limit ?? 50);
}

8.3 Cache

// lib/db/queries/analytics.cached.ts
import { unstable_cache } from 'next/cache';
import { revenueByPartner } from './analytics';

export const getCachedRevenueByPartner = unstable_cache(
  revenueByPartner,
  ['analytics:revenue-by-partner'],
  { tags: ['analytics', 'transactions', 'coupon_usages'], revalidate: 300 },
);

Tags invalidadas em:

Evento Tags revalidateTag
Webhook insere coupon_usages analytics, transactions, coupon_usages
Admin edita parceiro partners, analytics
Admin edita cupom coupons, analytics

Regra: queries usadas em páginas (admin)/dashboard, /analytics e /partners/[id] passam por unstable_cache com tags. Listagens com filtros dinâmicos NÃO usam cache — são rápidas o suficiente com índices.

8.4 Timezone nas Agregações

Decisão: armazenar UTC; agregar no timezone do tenant (default America/Sao_Paulo).

// Agrupar por dia no timezone correto:
sql<string>`to_char(${couponUsages.redeemedAt} AT TIME ZONE 'America/Sao_Paulo', 'YYYY-MM-DD')`

Filtros de período recebidos do cliente são convertidos em UTC server-side antes da query:

// lib/utils/dates.ts
import { fromZonedTime } from 'date-fns-tz';

export function toUtcRange(localFrom: Date, localTo: Date, tz = 'America/Sao_Paulo') {
  return { from: fromZonedTime(localFrom, tz), to: fromZonedTime(localTo, tz) };
}

9. Validação e Tipagem

9.1 Schemas Zod

// lib/validation/coupon.ts
import { z } from 'zod';

export const createCouponSchema = z.object({
  code: z.string().min(3).max(40).regex(/^[A-Z0-9_-]+$/),
  name: z.string().min(1).max(120),
  partnerId: z.string().uuid().nullable(),
  discount: z.discriminatedUnion('type', [
    z.object({ type: z.literal('percent'), value: z.number().int().min(1).max(100) }),
    z.object({ type: z.literal('amount'),  value: z.number().int().min(1), currency: z.string().length(3) }),
  ]),
  maxRedemptions: z.number().int().positive().nullable(),
  validFrom: z.coerce.date().nullable(),
  validUntil: z.coerce.date().nullable(),
}).refine(
  (d) => !d.validUntil || !d.validFrom || d.validUntil > d.validFrom,
  { message: 'validUntil deve ser posterior a validFrom', path: ['validUntil'] },
);

export type CreateCouponInput = z.infer<typeof createCouponSchema>;

9.2 Compartilhamento de Tipos

9.3 Result Pattern

// lib/result.ts
export type Ok<T>  = { ok: true; data: T };
export type Err<E> = { ok: false; error: E };
export type Result<T, E> = Ok<T> | Err<E>;

export const ok  = <T>(data: T): Ok<T>  => ({ ok: true, data });
export const err = <E>(error: E): Err<E> => ({ ok: false, error });

export type ActionError = {
  code: 'VALIDATION' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'NOT_FOUND' | 'CONFLICT' | 'STRIPE_ERROR' | 'INTERNAL';
  message: string;
  fields?: Record<string, string[]>;
};

Regra: Server Actions retornam Result. Erros não esperados (DB down, Stripe 5xx) ainda lançam exceção e são capturados por error.tsx + Sentry. Erros de domínio retornam Err.


10. UI e Componentes

10.1 shadcn/ui + Tailwind CSS 4

Justificativa: ownership do código (componentes copiados, não importados), tematização via CSS vars, integração nativa com Server Components, primitivos Radix sob o capô. Permite divergir do design padrão sem fork.

10.2 Recharts (via components/ui/chart)

Justificativa: estável, composicional, integrado ao componente Chart do shadcn (já tematizado). Cobre 100% dos gráficos necessários (linha, barra, área, donut). Tremor é mais opinado mas tem licença comercial menos flexível.

10.3 Padrões

Estado Padrão
Loading loading.tsx em cada route segment; <Suspense> com <Skeleton> para subseções.
Erro error.tsx por segmento; mensagem amigável + botão "tentar novamente"; logar no Sentry.
Empty Componente <EmptyState icon title description action /> em components/layout/empty-state.tsx.
Formulários react-hook-form + @hookform/resolvers/zod + Server Action via useActionState.
Toast sonner (shadcn). Disparado client-side após retorno de action.

11. Observabilidade e Auditoria

11.1 Audit Logs

Toda Server Action que modifica estado chama:

// server/services/audit.ts
export async function logAudit(input: {
  actorId: string;
  action: 'partner.create' | 'partner.update' | 'partner.deactivate'
        | 'coupon.create' | 'coupon.update' | 'coupon.deactivate' | 'coupon.assign'
        | 'user.password_reset' | 'reconciliation_drift';
  entity: 'partner' | 'coupon' | 'user' | 'system';
  entityId?: string;
  diff?: Record<string, { from: unknown; to: unknown }>;
  request?: { ip?: string; userAgent?: string };
}): Promise<void>;

ip e userAgent extraídos via headers() na própria action.

11.2 Logs e Erros

Camada Ferramenta O quê
Erros não capturados Sentry (@sentry/nextjs) Stack traces server+client, source maps, perf.
Logs estruturados Pino → Vercel Logs Eventos de webhook, reconciliação, jobs.
Métricas de produto Tabela audit_logs + dashboards próprios Não usar SaaS externo.

Configurar instrumentation.ts para inicializar Sentry e Pino.


12. Variáveis de Ambiente

Nome Descrição Exemplo Obrigatória
DATABASE_URL Connection string Neon (pooled) postgres://...neon.tech/db?sslmode=require
DATABASE_URL_UNPOOLED Conexão direta para migrations postgres://...neon.tech/db?sslmode=require
AUTH_SECRET Segredo Auth.js (32 bytes base64) openssl rand -base64 32
AUTH_URL URL pública https://app.example.com ✅ (prod)
STRIPE_SECRET_KEY API key Stripe sk_live_...
STRIPE_WEBHOOK_SECRET Endpoint signing secret whsec_...
STRIPE_API_VERSION Versão fixada da API 2025-09-30.acacia
CRON_SECRET Bearer dos endpoints /api/cron/* openssl rand -hex 32
RESEND_API_KEY Email transacional re_...
RESEND_FROM Remetente padrão no-reply@example.com
SENTRY_DSN DSN Sentry https://...@sentry.io/... ✅ (prod)
UPSTASH_REDIS_REST_URL Rate limit https://...upstash.io
UPSTASH_REDIS_REST_TOKEN Rate limit ...
APP_TIMEZONE TZ default agregações America/Sao_Paulo
NODE_ENV Runtime production

12.1 Validação Runtime

// lib/env.ts
import { z } from 'zod';

const schema = z.object({
  DATABASE_URL: z.string().url(),
  DATABASE_URL_UNPOOLED: z.string().url(),
  AUTH_SECRET: z.string().min(32),
  AUTH_URL: z.string().url().optional(),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
  STRIPE_API_VERSION: z.string(),
  CRON_SECRET: z.string().min(32),
  RESEND_API_KEY: z.string(),
  RESEND_FROM: z.string().email(),
  SENTRY_DSN: z.string().url().optional(),
  UPSTASH_REDIS_REST_URL: z.string().url(),
  UPSTASH_REDIS_REST_TOKEN: z.string(),
  APP_TIMEZONE: z.string().default('America/Sao_Paulo'),
  NODE_ENV: z.enum(['development', 'test', 'production']),
});

export const env = schema.parse(process.env);
export type Env = z.infer<typeof schema>;

Importar env em vez de process.env em todo o código.


13. Convenções de Código e DX

13.1 Nomenclatura

Item Padrão Exemplo
Arquivos React kebab-case.tsx partner-form.tsx
Componentes PascalCase PartnerForm
Funções/vars camelCase getPartnerById
Tipos/Interfaces PascalCase, sem prefixo I PartnerRevenueRow
Server Actions verbo + entidade createPartner, toggleCouponActive
Tabelas/colunas snake_case (plural) coupon_usages.redeemed_at
Rotas kebab-case /audit-logs

13.2 Imports

tsconfig.json com paths:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "@/*": ["./*"] },
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true
  }
}

Ordem de imports (ESLint import/order): builtin → externos → @/server@/lib@/components → relativos → estilos.

13.3 Lint, Format, Hooks

13.4 Commits

Conventional Commits: feat, fix, chore, refactor, docs, test, perf, ci. Scopes: (auth), (admin), (partner), (stripe), (db), (ui).

13.5 Scripts package.json

{
  "scripts": {
    "dev": "next dev --turbo",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "test:e2e": "playwright test",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio",
    "db:seed": "tsx scripts/seed.ts",
    "stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook"
  }
}

14. Roadmap de Implementação

Fase Marco verificável
1. Bootstrap Repo Next 15 + TS strict + Tailwind + shadcn + ESLint + Husky. pnpm typecheck e pnpm lint verdes.
2. Banco + Drizzle Schema completo, primeira migration aplicada no Neon dev. drizzle-kit studio lista todas as tabelas.
3. Auth.js Login funciona com Credentials, sessão JWT em cookie. Middleware redireciona por role. Seed cria 1 admin.
4. Recuperação de senha Fluxo end-to-end com Resend em dev (modo capture).
5. Admin — Parceiros CRUD completo + audit logs + soft delete. Listagem com filtros e busca.
6. Stripe outbound + Admin — Cupons Server Action createCoupon cria no Stripe e local em transação. Idempotency keys. CRUD completo.
7. Stripe webhook + idempotência Endpoint funcional via stripe listen. Tabela webhook_events populada. Eventos de coupon.* sincronizam.
8. Webhook financeiro checkout.session.completed e invoice.paid inserem transactions + coupon_usages corretamente.
9. Dashboards Query revenueByPartner + Recharts no admin. Cache via unstable_cache + tags.
10. Área do Parceiro Páginas read-only com requirePartnerScope. Métricas próprias.
11. Cron de reconciliação vercel.json configurado, endpoint protegido, drift logado em audit_logs.
12. Exportação CSV Endpoint /api/export/csv com streaming. Respeita escopo de role.
13. Observabilidade Sentry server+client, Pino em webhooks/cron, rate limit no login.
14. Testes Unit (Vitest) para queries/validation; E2E (Playwright) para login → criar parceiro → criar cupom → simular webhook → ver dashboard.
15. Produção Deploy Vercel, envs validadas, webhook configurado no Stripe live, Cron Jobs ativos. Smoke test do fluxo completo.

Cada fase deve terminar com merge em main e tag vX.Y.Z (semver). Não avançar sem todos os marcos verdes.