Skip to content

Checkout extraído para app frontend próprio (frontend only)

Extrai a UI de checkout de frontend/apps/web para um novo app irmão, frontend/apps/checkout (TanStack Start, mesmo stack de apps/web, deploy próprio via SST/Cloudflare Workers). Ao contrário de ADR 0008, o backend não é duplicado: apps/checkout chama o mesmo backend/apps/payments (Django) que o resto da aplicação, via o mesmo Payment/Paywall/gateway únicos.

Contexto

ADR 0013 trouxe o checkout de volta para dentro de apps/web como CheckoutDialog.tsx, revertendo o app standalone da ADR 0008 (SvelteKit + microserviço Hono próprio + Neon DB próprio) — o motivo do revert foi que o backend duplicado nunca funcionou de ponta a ponta (o webhook de enrollment cross-serviço postava para um endpoint que nunca existiu no Django).

O motivo original da ADR 0008 (tamanho do bundle do app principal, deploy isolado do checkout) continua válido e não foi endereçado pela 0013. A diferença desta vez: extrair só o frontend, mantendo backend/apps/payments como único backend de pagamento — elimina a causa raiz do bug da 0008 (dois backends, dois bancos, hop de webhook quebrado) sem abrir mão do isolamento de deploy.

Decisão

  • Novo app frontend/apps/checkout/ (TanStack Start), com sua própria rota /checkout/$spaceId reimplementando a state machine que vivia em CheckoutDialog.tsx (Pix/Boleto/Card, polling de status, fake-pay em dev).
  • Sem sessão/cookie compartilhado entre apps. apps/web gera o link de checkout (lib/api/checkout-link.functions.ts) carregando o access token JWT (já curto, SimpleJWT ACCESS_TOKEN_LIFETIME) e o slug do tenant na querystring: ${CHECKOUT_BASE_URL}/checkout/${spaceId}?token=&slug=&returnUrl=. apps/checkout persiste esse token num cookie httpOnly de vida curta (checkout-session.server.ts) e o usa como Authorization: Bearer em todas as chamadas ao Django — sem Redis, sem refresh, sem CORS (a chamada ao Django continua sendo server-to-server dentro do Worker do checkout).
  • Ao concluir o pagamento, apps/checkout redireciona de volta para returnUrl?enrolled=1; as rotas de origem em apps/web (courses/$courseId, courses/$courseId/$lessonId, products/$packageId, products/$packageId/$itemId, spaces/$spaceId) detectam esse parâmetro e invalidam as queries de paywall/conteúdo — substitui o callback onEnrolled que existia no dialog.
  • frontend/sst.config.ts ganha um recurso Checkout (TanStackStart); a env var CHECKOUT_BASE_URL do Web (órfã desde a ADR 0013, sobrando da 0008) passa a apontar direto pro checkout.url do novo recurso.
  • SpaceLockScreen (routes/spaces/$spaceId.tsx) tinha uma implementação de checkout inline própria e inconsistente com o CheckoutDialog (esperava um campo checkout_url que não existe em CheckoutResponse). Migrada para o mesmo padrão de link + enrolled=1.

Consequências

  • Positivo: um único backend/ledger de pagamento (apps.payments), mesmo em dois frontends — corrige a causa raiz do bug da ADR 0008 sem reintroduzir DB/serviço duplicado.
  • Positivo: bundle de apps/web não carrega mais a UI de checkout; apps/checkout pode ser deployado/escalado/revertido independentemente.
  • Negativo: o handoff por token na URL expõe um access token JWT short-lived em query string (logs de proxy/browser history) — mesmo trade-off que a ADR 0008 já aceitava; mitigado pelo TTL curto (30 min) e por não haver refresh (token expirado força reabrir o link a partir de apps/web, que gera um novo).
  • Negativo: CSRF_TRUSTED_ORIGINS (Django) precisa incluir a origin do novo app em cada ambiente — configuração por env var, sem mudança de código (ver docs/BACKEND_TECHNICAL.md).
  • frontend/apps/checkout/src/routes/checkout.$spaceId.tsx — a página de checkout.
  • frontend/apps/checkout/src/lib/checkout-session.server.ts — cookie de handoff do token.
  • frontend/apps/web/src/lib/api/checkout-link.functions.ts — gerador do link de checkout.
  • backend/apps/payments/views.py — backend único de checkout (inalterado).
  • ADR 0008 / ADR 0013 — histórico desta decisão.

Strum — Documentação.