ARQUIVO HISTORICO / LEGADO. Preservado como memoria tecnica; nao descreve o runtime atual e nao deve ser usado como runbook operacional.
Billing — Stripe + Espelhamento GHL¶
⚠️ Doc pré-PDL-505 (11/06/2026). Este doc foi escrito quando o Cadência tinha planos recorrentes via Stripe Subscription. Isso não existe mais — o modelo migrou pra créditos puros (só
mode: "payment"one-time,CREDIT_PACKSemplans.ts; semmode: "subscription", sem "Essencial/Profissional/Business/Growth Pro"). Fonte única do modelo atual:times/produto/cadencia/MODELO-CREDITOS.md(pd-framework). As seções abaixo que mencionam "assinar plano"/mode=subscriptiondescrevem o fluxo antigo — mantidas como referência histórica do fluxo de webhook/GHL (que em boa parte ainda se aplica), mas não confiar na parte de planos.
📍 Identidade¶
| Tipo | Feature transversal (lib + API routes + DB + webhook + integração externa) |
| Stack | Next.js 15 (App Router) · Stripe SDK Node v17 · Supabase Postgres · GoHighLevel REST API v2 |
| Conta Stripe | Axis (BRL · livemode) |
| Webhook endpoint | we_1TW0vXQnLaAmF0Bc1BQgJipi → https://cadencia.app.br/api/webhooks/stripe |
| GHL subaccount | Cadencia — antiga PD (PrAh9rKjmpUkElCu5KBI) |
| Status | ✅ Produção. E2E validado com pagamento real + refund (11/05/2026). |
✨ TL;DR¶
Cliente paga no Stripe → webhook do Cadencia recebe → libera créditos no Supabase + cria invoice paga no GHL automaticamente. Refund segue caminho equivalente (status → refunded, tag GHL refund-stripe). Stripe é fonte da verdade; GHL é espelho de visualização/CRM.
🎯 Pra que serve¶
- ~~Cobrar planos recorrentes do SaaS Cadencia~~ REMOVIDO (PDL-505) — Cadência é créditos puros, sem assinatura
- Cobrar pacotes de créditos one-time (+10/+30/+60 créditos,
CREDIT_PACKSemplans.ts) - Cobrar clientes B2B de consultoria (GCI GO, Nathalia, Rogéria, Alvina) com regras específicas (cancel_at, send_invoice por boleto, parcelamento)
- Visualizar pagamentos/refunds no GHL Cadencia (CRM) sem código no GHL — webhook custom faz o trabalho
⚙️ Como funciona — fluxo end-to-end¶
Pagamento (compra de créditos — fluxo atual, pós-PDL-505)¶
[1] Cliente em /app/plans clica num pacote de créditos (+10/+30/+60)
↓
[2] POST /api/app/checkout
• Lê pacote de CREDIT_PACKS (plans.ts)
• findOrCreateCustomer(stripe) — cria/recupera Customer
• createCheckoutSession(stripe)
- mode=payment (sempre — sem subscription desde PDL-505)
- line_items: price stripe_price_id
- metadata: {tenant_id, plan_slug}
• Salva stripe_customer_id em tenant_config.config
• Retorna session.url
↓
[3] Cliente redirecionado pra hosted page Stripe
Paga com cartão
↓
[4] Stripe webhook → POST /api/webhooks/stripe
verifyWebhookSignature() — valida assinatura whsec_*
↓
[5] Idempotência: SELECT em stripe_webhook_log
Se já processou: retorna {status:"already_processed"}
Se não: INSERT (processed=false)
↓
[6] Switch event.type:
case checkout.session.completed:
- INSERT em tenant_plans (status=active, créditos liberados)
- Bridge GHL via Workers (Coolify VPS Master /api/v1/ghl/payment) — tag+pipeline _(GHL legado)_
- mirrorPaidInvoiceToGhl():
· stripe.customers.retrieve() → pega email
· findContactByEmail() → busca Contact GHL
· createInvoice() no GHL (paid + metadata stripe_invoice_id)
· recordInvoicePayment() — marca como recebida
· addContactTag(["cliente-pagante", "plano:<slug>"])
case invoice.paid (renovação subscription):
- Idempotência via stripe_invoice_id (evita criar duplicata se checkout.session já criou)
- INSERT novo tenant_plans com créditos resetados
- mirrorPaidInvoiceToGhl() (renewal)
↓
[7] UPDATE stripe_webhook_log SET processed=true
↓
[8] Cliente volta pra /app/plans/callback?status=success
Vê os créditos liberados no /app
Refund¶
[1] Operador clica Refund no Stripe Dashboard
↓
[2] Stripe webhook → POST /api/webhooks/stripe (event=charge.refunded)
↓
[3] Switch case "charge.refunded":
Resolve tenant_id por cascata:
a) charge.metadata.tenant_id
b) charge.invoice → invoice.subscription_details.metadata.tenant_id
c) charge.payment_intent → pi.metadata.tenant_id
UPDATE tenant_plans SET status='refunded' WHERE plano ativo mais recente
GHL: addContactTag(["refund-stripe"]) no contact correspondente
↓
[4] Cliente perde acesso aos créditos (app respeita status≠active)
🧠 Decisões arquiteturais¶
Ver ADR-0001 — Stripe em vez de Asaas.
Decisões secundárias:
- Stripe é fonte da verdade, GHL é espelho. Subscriptions criadas via API Stripe direto (não via funnel GHL). GHL recebe espelho via webhook custom (a integração nativa Stripe↔GHL NÃO puxa subs criadas externamente — só processa as que nascem no GHL).
- Big bang Asaas → Stripe. Sem feature flag, sem convivência. Justificativa: nenhum cliente pagante no SaaS na data do refactor.
- Cartão only no SaaS. Sem Pix/boleto pra reduzir fricção da Tiazinha Véia.
send_invoicepros B2B (boleto) — Stripe hospeda página de pagamento, cliente escolhe método.- Nathalia consultoria com
cancel_at=2026-12-21em vez de Stripe Subscription Schedule (mais simples). - Idempotência via
stripe_webhook_logcomevent_idunique constraint. Garante que retentativas do Stripe não duplicam efeitos. - Mirror GHL é fire-and-forget dentro do webhook. Erros do GHL NÃO derrubam o processamento do Stripe — logam e seguem.
🚫 Don'ts¶
- Não criar subscriptions Cadencia SaaS direto no GHL (Invoice Schedule) — GHL perde flexibilidade de proration/addons que a Stripe API oferece.
- Não passar Stripe key em código — sempre via
process.env.STRIPE_SECRET_KEY(lazy init emsrc/lib/stripe.ts). - Não confiar em
charge.metadata.tenant_id— Stripe NÃO herda metadata da subscription pro charge. Sempre resolver via subscription/payment_intent. Esse foi o bug do commit24e2634. - Não exigir
session.invoiceno mirror GHL — addons one-time têmsession.invoice=null(Stripe só cria invoice pra subscriptions). Usarpayment_intentousession.idcomo fallback. Bug do commit866e27b. - Não enviar
namevazio pro endpoint/invoices/do GHL — 422 silencioso. Campos obrigatórios:name,businessDetails,currencypor item. Bug do commit1a1d565. - Não void invoice GHL via API com PIT — não suportado. Operação destrutiva precisa ser feita manualmente no Dashboard GHL ou via OAuth com scope diferente.
🔥 Troubleshooting¶
Pagamento OK no Stripe mas créditos não liberaram no Cadencia¶
- Conferir
stripe_webhook_logno Supabase — webhook chegou? Foiprocessed=true? - Se não chegou: ver Webhook Deliveries no Stripe Dashboard (
https://dashboard.stripe.com/webhooks/we_1TW0vXQnLaAmF0Bc1BQgJipi). Stripe mostra status HTTP por entrega. - Se chegou mas falhou: ver Vercel logs →
vercel logs cadencia-app. Filtra porStripe webhook error. - Verificar idempotência — se mesmo
event_idfoi processado antes, retornaalready_processed. Apagar a row pra forçar reprocessamento (raríssimo).
Invoice GHL não foi criada após pagamento Stripe¶
- Vercel logs → buscar
mirrorPaidInvoiceToGhl error. - Conferir
GHL_API_KEYno Vercel é o PIT da subaccount certa (PrAh9rKjmpUkElCu5KBI). Comum: ter trocado de agência GHL e env zumbi apontando pra antiga. - Conferir email do customer Stripe bate com email do Contact GHL —
findContactByEmail()retorna null se não houver match. - Se erro for 422 da
/invoices/: regredir pro fix do commit1a1d565(campos obrigatóriosname,businessDetails, currency por item).
Refund Stripe não muda status no Supabase¶
- Versões antigas (antes do commit
24e2634) tinham bug —charge.metadata.tenant_idé sempre vazio. Atualizar. - Se ainda falha mesmo com commit novo: customer Stripe não tem subscription/PI metadata corretos. Migrar customer manualmente via SQL.
Sub Stripe em trial não tem invoice ainda¶
- Esperado. Subscriptions criadas com
trial_endfuturo só geram invoice no fim do trial. Webhookinvoice.paiddispara nesse momento.
Múltiplos clientes Stripe compartilham mesmo email¶
- Caso real: 21 clínicas Sorria Rio/Vamos Sorrir usam
alicia.ramos@gci.com.br. GHL não permite Contacts duplicados por email — 1 Contact GHL representa N customers Stripe. Não é bug.
🪦 Já tentamos¶
- Integração nativa Stripe ↔ GHL pra ver subs criadas via API — não funciona. Ela só conhece transações que nascem dentro do GHL (Invoice/funnel/payment link do próprio GHL). Validado em 11/05/2026 com
GET /payments/integrations/provider/whitelabelretornandoproviders:[]mesmo após OAuth conectado. - Custom Provider GHL pra usar Asaas como gateway externo do GHL — não é o que queremos. É o caminho inverso (
GHL → Stripe API). Cadencia SaaS querStripe API → GHL espelho.
📚 Referências¶
Código¶
src/lib/stripe.ts— cliente Stripe + helpers (lazy init pra Next build)src/lib/ghl.ts— helpers GHL (findContactByEmail, createInvoice, recordInvoicePayment, addContactTag)src/lib/plans.ts— catálogo de planos comstripe_price_idsrc/app/api/app/checkout/route.ts— endpoint que cria Checkout Sessionsrc/app/api/webhooks/stripe/route.ts— webhook handler (6 events + mirror GHL)supabase/migrations/20260511000000_stripe_integration.sql— migration (stripe_webhook_log + colunas)
Externos¶
- Dashboard Stripe: https://dashboard.stripe.com (conta Axis)
- Dashboard GHL: https://app.gohighlevel.com (subaccount Cadencia)
- Webhook endpoint Stripe: https://dashboard.stripe.com/webhooks/we_1TW0vXQnLaAmF0Bc1BQgJipi
- Stripe SDK Node docs: https://github.com/stripe/stripe-node
- GHL Invoices API: https://highlevel.stoplight.io/docs/integrations (rotas
/invoices/)
Decisões¶
Logs de sessão¶
Hub Projetos/Rotina/sessions-log/2026-05-11/cadencia-migracao-asaas-stripe-ghl_2026-05-11_2229.md
📜 Histórico¶
| Data | Mudança | Commit |
|---|---|---|
| 2026-05-11 | Feature criada (big bang Asaas→Stripe + mirror GHL) | 74634b8, 674f620 |
| 2026-05-11 | Fix payload /invoices/ GHL (campos obrigatórios) | 1a1d565 |
| 2026-05-11 | Fix charge.refunded extrai tenant_id de subscription/PI | 24e2634 |
| 2026-05-11 | Fix mirror suporta one-time (addons sem session.invoice) | 866e27b |
| 2026-05-11 | E2E validado em produção (pagamento R$49,90 → INV-000003 GHL automático em 1s; refund automático) | — |