Stripe — Integração Completa para Frontend¶
Data: 2026-03-01 | Status: Implementado | Gateway: Stripe Billing API
📋 Visão Geral¶
O FDPlay agora suporta dois gateways de pagamento em paralelo:
| Gateway | Métodos | Criptografia | Rotas |
|---|---|---|---|
| Asaas | Cartão (dados brutos), PIX, Boleto | Nenhuma (dados enviados diretamente ao backend) | /api/v1/customers/me/subscribe |
| Stripe | Cartão (Stripe Elements/SDK), PIX | Stripe SDK (PCI Level 1) | /api/v1/stripe/subscribe, /api/v1/stripe/subscribe/pix |
Rotas Isoladas
Stripe e Asaas operam em rotas completamente separadas. O frontend escolhe qual gateway usar no momento da assinatura. O middleware de acesso a vídeos (require_active_subscription) é gateway-agnostic — só verifica status, payment_failures e expires_at.
🏗️ Arquitetura¶
sequenceDiagram
participant App as Flutter App
participant SDK as Stripe SDK
participant API as FDPlay API
participant Stripe as Stripe API
participant DB as MongoDB
App->>API: GET /api/v1/stripe/config
API-->>App: {publishable_key: "pk_live_..."}
App->>SDK: Stripe.init(publishable_key)
App->>SDK: createPaymentMethod(card)
SDK->>Stripe: Tokenizar cartão
Stripe-->>SDK: pm_xxx (PaymentMethod ID)
SDK-->>App: pm_xxx
App->>API: POST /api/v1/stripe/subscribe<br/>{plan_id, payment_method_id: pm_xxx}
API->>Stripe: Customer.create() (se novo)
API->>Stripe: PaymentMethod.attach(pm_xxx, cus_xxx)
API->>Stripe: Subscription.create(cus_xxx, price_xxx)
Stripe-->>API: sub_xxx + status
alt Pagamento aprovado (status=active)
API->>DB: Salvar Subscription(status='active')
API-->>App: {payment_status: 'succeeded'}
Note over App: Navegar para home
end
alt 3D Secure necessário (status=incomplete + client_secret)
API->>DB: Salvar Subscription(status='pending')
API-->>App: {payment_status: 'requires_action', client_secret}
App->>SDK: confirmPayment(client_secret)
SDK->>Stripe: Autenticar 3DS
Stripe-->>SDK: Sucesso
Stripe->>API: Webhook: customer.subscription.updated (active)
API->>DB: Atualizar status = 'active'
end
alt Pagamento recusado (status=incomplete, sem PI)
API->>DB: Salvar Subscription(status='cancelled')
API-->>App: {payment_status: 'requires_payment_method', payment_failed: true}
Note over App: Exibir erro + permitir retry
end
🔑 Endpoints Stripe¶
Tabela de Rotas¶
| Método | Endpoint | Auth | Descrição |
|---|---|---|---|
GET |
/api/v1/stripe/config |
Nenhuma | Obter publishable key para inicializar Stripe SDK |
POST |
/api/v1/stripe/subscribe |
JWT (Customer) | Criar assinatura Stripe (cartao) |
POST |
/api/v1/stripe/subscribe/pix |
JWT (Customer) | Criar assinatura Stripe PIX (anual) |
GET |
/api/v1/stripe/subscription |
JWT (Customer) | Status da assinatura Stripe ativa |
PUT |
/api/v1/stripe/subscription/payment-method |
JWT (Customer) | Trocar método de pagamento (in-place) |
DELETE |
/api/v1/stripe/subscription |
JWT (Customer) | Cancelar assinatura Stripe |
POST |
/webhooks/stripe |
Stripe Signature | Webhook receiver (server-to-server) |
📱 Integração Flutter — Passo a Passo¶
1. Adicionar Dependência¶
Configuração Nativa
O flutter_stripe requer configuração nativa adicional:
Android (android/app/build.gradle):
iOS (ios/Podfile):
2. Obter Publishable Key e Inicializar Stripe SDK¶
IMPORTANTE — Não hardcodar a publishable key
A publishable_key deve ser obtida em runtime via GET /api/v1/stripe/config.
NUNCA hardcodar a key no código-fonte ou no build. Isso evita que mudanças de conta Stripe
exijam rebuild e redeploy do app.
Endpoint: GET /api/v1/stripe/config¶
Response (200):
Response (500 — chave não configurada):
Código Dart¶
import 'package:flutter_stripe/flutter_stripe.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
class StripeService {
final String baseUrl = 'https://fdplay-api.infraifd.com';
/// Buscar publishable key do backend e inicializar Stripe SDK.
/// Chamar uma vez no main() ou antes da tela de pagamento.
Future<void> initialize() async {
final response = await http.get(
Uri.parse('$baseUrl/api/v1/stripe/config'),
);
if (response.statusCode != 200) {
throw Exception('Falha ao obter configuração Stripe');
}
final data = json.decode(response.body);
Stripe.publishableKey = data['publishable_key'];
await Stripe.instance.applySettings();
}
}
Quando inicializar
Chame StripeService().initialize() uma vez no main() ou no initState() da tela de pagamento.
A key pode ser cacheada em memória durante a sessão do app.
3. Coletar Dados do Cartão e Criar PaymentMethod¶
/// Criar PaymentMethod via Stripe SDK (tokenização segura)
Future<String> createPaymentMethod() async {
// O Stripe SDK exibe seu próprio formulário seguro (CardField)
// ou você pode usar createPaymentMethod programaticamente:
final paymentMethod = await Stripe.instance.createPaymentMethod(
params: PaymentMethodParams.card(
paymentMethodData: PaymentMethodData(
billingDetails: BillingDetails(
name: 'JOAO DA SILVA',
email: 'joao@example.com',
),
),
),
);
return paymentMethod.id; // pm_1234567890abcdef
}
4. Widget CardField (Formulário Seguro)¶
class StripePaymentForm extends StatefulWidget {
@override
_StripePaymentFormState createState() => _StripePaymentFormState();
}
class _StripePaymentFormState extends State<StripePaymentForm> {
CardFieldInputDetails? _cardDetails;
bool _isLoading = false;
@override
Widget build(BuildContext context) {
return Column(
children: [
// Formulário de cartão seguro do Stripe (PCI Level 1)
CardField(
onCardChanged: (card) {
setState(() => _cardDetails = card);
},
decoration: InputDecoration(
labelText: 'Dados do Cartão',
border: OutlineInputBorder(),
),
),
SizedBox(height: 24),
ElevatedButton(
onPressed: _cardDetails?.complete == true && !_isLoading
? _handleSubscribe
: null,
child: _isLoading
? CircularProgressIndicator()
: Text('Assinar com Stripe'),
),
],
);
}
Future<void> _handleSubscribe() async {
setState(() => _isLoading = true);
try {
final service = StripeSubscriptionService(
getToken: () => yourUserToken,
);
final result = await service.subscribe(planId: 'plan-basic');
final info = result['info'] as Map<String, dynamic>;
// Pagamento recusado (cartão sem saldo, bloqueado, etc.)
if (info['payment_failed'] == true) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(info['message'] ?? 'Pagamento recusado.')),
);
return;
}
// 3D Secure necessário — confirmar pagamento
if (info['client_secret'] != null) {
await Stripe.instance.confirmPayment(
paymentIntentClientSecret: info['client_secret'],
);
}
// Sucesso!
Navigator.pushReplacementNamed(context, '/home');
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erro: $e')),
);
} finally {
setState(() => _isLoading = false);
}
}
}
5. Serviço de Assinatura Stripe¶
class StripeSubscriptionService {
final String baseUrl = 'https://fdplay-api.infraifd.com';
final String Function() getToken;
StripeSubscriptionService({required this.getToken});
/// Criar assinatura Stripe
Future<Map<String, dynamic>> subscribe({
required String planId,
}) async {
// 1. Criar PaymentMethod via Stripe SDK
final paymentMethod = await Stripe.instance.createPaymentMethod(
params: PaymentMethodParams.card(
paymentMethodData: PaymentMethodData(),
),
);
// 2. Enviar para backend criar a subscription
final response = await http.post(
Uri.parse('$baseUrl/api/v1/stripe/subscribe'),
headers: {
'Authorization': 'Bearer ${getToken()}',
'Content-Type': 'application/json',
},
body: json.encode({
'plan_id': planId,
'payment_method_id': paymentMethod.id, // pm_xxx
}),
);
if (response.statusCode != 201) {
final error = json.decode(response.body);
throw Exception(error['detail'] ?? 'Erro ao criar assinatura');
}
return json.decode(response.body);
}
/// Consultar assinatura ativa
Future<Map<String, dynamic>> getSubscription() async {
final response = await http.get(
Uri.parse('$baseUrl/api/v1/stripe/subscription'),
headers: {
'Authorization': 'Bearer ${getToken()}',
},
);
if (response.statusCode != 200) {
throw Exception('Sem assinatura ativa');
}
return json.decode(response.body);
}
/// Cancelar assinatura (mantém acesso até fim do período)
Future<void> cancelSubscription() async {
final response = await http.delete(
Uri.parse('$baseUrl/api/v1/stripe/subscription'),
headers: {
'Authorization': 'Bearer ${getToken()}',
},
);
if (response.statusCode != 200) {
final error = json.decode(response.body);
throw Exception(error['detail'] ?? 'Erro ao cancelar');
}
}
/// Trocar método de pagamento (in-place, sem cancelar)
Future<void> updatePaymentMethod() async {
// 1. Criar novo PaymentMethod via Stripe SDK
final paymentMethod = await Stripe.instance.createPaymentMethod(
params: PaymentMethodParams.card(
paymentMethodData: PaymentMethodData(),
),
);
// 2. Enviar para backend atualizar
final response = await http.put(
Uri.parse('$baseUrl/api/v1/stripe/subscription/payment-method'),
headers: {
'Authorization': 'Bearer ${getToken()}',
'Content-Type': 'application/json',
},
body: json.encode({
'payment_method_id': paymentMethod.id,
}),
);
if (response.statusCode != 200) {
final error = json.decode(response.body);
throw Exception(error['detail'] ?? 'Erro ao atualizar');
}
}
}
🔄 Fluxos Detalhados¶
Fluxo 1: Criar Assinatura¶
POST /api/v1/stripe/subscribe
Authorization: Bearer eyJhbGci...
Content-Type: application/json
{
"plan_id": "plan-basic",
"payment_method_id": "pm_1234567890abcdef"
}
Response (201 — Ativação imediata):
{
"docs": [
{
"_id": "507f1f77bcf86cd799439011",
"customer_id": "507f1f77bcf86cd799439012",
"plan_id": "plan-basic",
"gateway": "stripe",
"stripe_subscription_id": "sub_1RChxWG6Qr6aGiCI",
"stripe_price_id": "price_1RChxWG6Qr6aGiCI",
"status": "active",
"payment_method": "credit_card",
"started_at": "2026-02-27T14:30:00Z",
"next_billing_date": "2026-03-27T14:30:00Z",
"payment_failures": 0,
"created_at": "2026-02-27T14:30:00Z"
}
],
"info": {
"message": "Stripe subscription created successfully. You can now access premium content.",
"client_secret": null
},
"links": [],
"msg": "ok",
"pagination": {
"current_page": 0,
"qty_docs_page": 1,
"qty_of_pages": 1,
"qty_total_docs": 1
}
}
Response (201 — 3D Secure necessário):
{
"docs": [
{
"_id": "507f1f77bcf86cd799439011",
"status": "pending",
"gateway": "stripe",
"stripe_subscription_id": "sub_1RChxWG6Qr6aGiCI"
}
],
"info": {
"message": "Stripe subscription created. Complete 3D Secure authentication to activate.",
"client_secret": "pi_3RChxWG6Qr6aGiCI_secret_xxx"
},
"links": [],
"msg": "ok",
"pagination": {
"current_page": 0,
"qty_docs_page": 1,
"qty_of_pages": 1,
"qty_total_docs": 1
}
}
Response (201 — Pagamento recusado):
{
"docs": [
{
"_id": "507f1f77bcf86cd799439011",
"status": "cancelled",
"gateway": "stripe",
"stripe_subscription_id": "sub_1RChxWG6Qr6aGiCI"
}
],
"info": {
"payment_status": "requires_payment_method",
"payment_failed": true,
"message": "Pagamento recusado. Tente outro método de pagamento."
},
"links": [],
"msg": "ok",
"pagination": {
"current_page": 0,
"qty_docs_page": 1,
"qty_of_pages": 1,
"qty_total_docs": 1
}
}
Tratamento obrigatório — Pagamento recusado
O frontend deve verificar info.payment_failed == true na resposta do POST /subscribe.
Quando presente, o pagamento foi recusado pelo emissor do cartão (saldo insuficiente, cartão bloqueado, etc.).
A subscription é criada com status: cancelled — o cliente não recebe acesso.
O frontend deve exibir a mensagem de info.message e permitir que o cliente tente novamente com outro cartão.
Valores possíveis de info.payment_status:
| Valor | Significado | Ação do frontend |
|---|---|---|
succeeded |
Pagamento confirmado | Navegar para home — acesso liberado |
requires_action |
3D Secure necessário | Chamar confirmPayment(client_secret) |
requires_payment_method |
Cartão recusado | Exibir erro + permitir retry |
unknown |
Status indeterminado | Exibir client_secret se disponível, ou iniciar polling |
3D Secure (SCA)
Quando o response contém client_secret, o frontend deve chamar Stripe.instance.confirmPayment(paymentIntentClientSecret: clientSecret) para completar a autenticação 3D Secure. Após confirmação, o Stripe envia um webhook customer.subscription.updated com status=active e o backend atualiza automaticamente.
Fluxo 2: Consultar Assinatura¶
Response (200):
{
"docs": [
{
"_id": "507f1f77bcf86cd799439011",
"gateway": "stripe",
"status": "active",
"plan_id": "plan-basic",
"stripe_subscription_id": "sub_1RChxWG6Qr6aGiCI",
"next_billing_date": "2026-03-27T14:30:00Z",
"last_payment_at": "2026-02-27T14:30:00Z",
"payment_failures": 0
}
],
"info": {
"message": null,
"client_secret": null
},
"links": [],
"msg": "ok",
"pagination": {
"current_page": 0,
"qty_docs_page": 1,
"qty_of_pages": 1,
"qty_total_docs": 1
}
}
Resolução automática de status pendente
Quando a subscription retornada tem status: pending e gateway: stripe, o backend
consulta o Stripe em tempo real antes de responder. Se o Stripe reportar que a
subscription está incomplete, incomplete_expired ou canceled, o backend atualiza
automaticamente para cancelled e limpa current_subscription_id.
Isso significa que o frontend pode usar polling em GET /customers/me/subscription
com segurança: o endpoint nunca retornará pending indefinidamente para subscriptions
Stripe com pagamento recusado.
| Status retornado | Significado | Ação do frontend |
|---|---|---|
active |
Pagamento confirmado | Liberar acesso ao conteúdo |
pending |
Aguardando confirmação (3DS, processamento) | Continuar polling |
cancelled |
Pagamento falhou ou expirou | Exibir erro + permitir nova tentativa |
404 |
Sem subscription (cancelada pelo backend) | Redirecionar para tela de assinatura |
Response (404 — sem assinatura):
Response (400 — assinatura não é Stripe):
Fluxo 3: Trocar Método de Pagamento¶
PUT /api/v1/stripe/subscription/payment-method
Authorization: Bearer eyJhbGci...
Content-Type: application/json
{
"payment_method_id": "pm_newcard567890"
}
Response (200):
Vantagem sobre Asaas
No Stripe, a troca de método de pagamento é feita in-place — sem necessidade de cancelar e recriar a assinatura. Isso é mais simples e mantém o histórico de pagamentos intacto.
Fluxo 4: Cancelar Assinatura¶
Response (200):
{
"message": "Stripe subscription cancelled successfully.",
"cancelled_at": "2026-02-27T16:00:00Z"
}
Cancelamento no fim do período
Por padrão, o cancelamento Stripe ocorre no fim do período de cobrança atual (cancel_at_period_end=True). O customer mantém acesso até a data de expiração.
🟢 Stripe PIX¶
O Stripe suporta PIX via parceria EBANX. Diferente do cartao (recorrente), PIX e um pagamento unico.
Modo de cobranca PIX (periodo)
O valor do PIX e controlado pela config pix_billing_mode (ver Modo de Cobranca PIX).
Valores: monthly (1m), quarterly (3m), semiannually (6m), yearly (12m).
O frontend consulta GET /api/v1/config/pix-billing-mode para exibir o valor correto.
O frontend deve enviar o amount (em centavos BRL) no corpo da requisicao.
Desconto com cupom
O desconto discount_first_month substitui o preco de 1 mes dentro do periodo: valor_final = ((meses - 1) × preco_mensal) + discount_first_month. Funciona em todos os modos (monthly, quarterly, semiannually, yearly). O campo promo_code pode ser enviado no payload do subscribe.
Fluxo PIX¶
sequenceDiagram
participant App as Flutter App
participant API as FDPlay API
participant Stripe as Stripe API
participant DB as MongoDB
App->>API: POST /api/v1/stripe/subscribe/pix<br/>{plan_id, amount}
API->>Stripe: PaymentIntent.create(<br/>payment_method_types=['pix'],<br/>confirm=True)
Stripe-->>API: pi_xxx + QR code data
API->>DB: Salvar Subscription(status='pending',<br/>gateway='stripe', payment_method='pix',<br/>stripe_payment_intent_id=pi_xxx)
API-->>App: {subscription, pix: {qr_code, qr_code_image_url}}
Note over App: Exibir QR code para o cliente
App->>App: Cliente escaneia QR code e paga
Stripe->>API: POST /webhooks/stripe<br/>payment_intent.succeeded
API->>DB: Atualizar: status='active',<br/>expires_at=now+1year
Note over App: Cliente pode acessar videos!
Criar Assinatura PIX¶
POST /api/v1/stripe/subscribe/pix
Authorization: Bearer eyJhbGci...
Content-Type: application/json
{
"plan_id": "plan-basic",
"amount": 23880
}
Campos obrigatorios no Customer
O PIX Stripe exige tax_id (CPF) no customer. Certifique-se de que o customer tem tax_id preenchido antes de chamar esta rota.
Response (201 — QR Code gerado):
{
"docs": [
{
"_id": "507f1f77bcf86cd799439011",
"customer_id": "507f1f77bcf86cd799439012",
"plan_id": "plan-basic",
"gateway": "stripe",
"stripe_payment_intent_id": "pi_3RChxWG6Qr6aGiCI",
"status": "pending",
"payment_method": "pix",
"started_at": "2026-03-02T14:30:00Z",
"payment_failures": 0,
"created_at": "2026-03-02T14:30:00Z"
}
],
"info": {
"message": "PIX subscription created. Scan QR code to pay. Payment expires in 30 minutes.",
"client_secret": null,
"pix": {
"qr_code": "00020126580014br.gov.bcb.pix0136...",
"qr_code_image_url": "https://stripe.com/qr/...",
"expires_at": "2026-03-02T15:00:00Z",
"hosted_instructions_url": "https://payments.stripe.com/..."
},
"payment_intent_id": "pi_3RChxWG6Qr6aGiCI",
"payment_status": "requires_action"
},
"links": [],
"msg": "ok",
"pagination": {
"current_page": 0,
"qty_docs_page": 1,
"qty_of_pages": 1,
"qty_total_docs": 1
}
}
Integracao Flutter — PIX Stripe¶
class StripePixService {
final String baseUrl = 'https://fdplay-api.infraifd.com';
final String Function() getToken;
StripePixService({required this.getToken});
/// Criar assinatura PIX via Stripe
Future<Map<String, dynamic>> subscribePix({
required String planId,
required int amount, // Em centavos BRL
}) async {
final response = await http.post(
Uri.parse('$baseUrl/api/v1/stripe/subscribe/pix'),
headers: {
'Authorization': 'Bearer ${getToken()}',
'Content-Type': 'application/json',
},
body: json.encode({
'plan_id': planId,
'amount': amount,
}),
);
if (response.statusCode != 201) {
final error = json.decode(response.body);
throw Exception(error['detail'] ?? 'Erro ao criar PIX');
}
return json.decode(response.body);
}
}
Widget PIX QR Code¶
class StripePixPayment extends StatefulWidget {
final String planId;
final int amount;
const StripePixPayment({
required this.planId,
required this.amount,
});
@override
_StripePixPaymentState createState() => _StripePixPaymentState();
}
class _StripePixPaymentState extends State<StripePixPayment> {
Map<String, dynamic>? _pixData;
bool _isLoading = false;
Future<void> _createPixPayment() async {
setState(() => _isLoading = true);
try {
final service = StripePixService(getToken: () => yourUserToken);
final result = await service.subscribePix(
planId: widget.planId,
amount: widget.amount,
);
setState(() => _pixData = result['info']['pix']);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erro: $e')),
);
} finally {
setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
if (_pixData == null) {
return ElevatedButton(
onPressed: _isLoading ? null : _createPixPayment,
child: _isLoading
? CircularProgressIndicator()
: Text('Pagar com PIX'),
);
}
return Column(
children: [
// QR Code image
Image.network(_pixData!['qr_code_image_url']),
SizedBox(height: 16),
// Copia e cola
SelectableText(_pixData!['qr_code']),
SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: _pixData!['qr_code']));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Codigo PIX copiado!')),
);
},
icon: Icon(Icons.copy),
label: Text('Copiar codigo PIX'),
),
SizedBox(height: 16),
Text(
'Pagamento expira em 30 minutos',
style: TextStyle(color: Colors.grey),
),
],
);
}
}
Ativacao automatica
Apos o pagamento PIX, o Stripe envia um webhook payment_intent.succeeded e o backend ativa automaticamente a subscription com expires_at = now + 1 ano. O frontend nao precisa fazer nada — basta redirecionar o customer para a home apos exibir o QR code.
Recuperar QR code PIX (abandono)
Se o customer fechar o app antes de pagar, pode recuperar o QR code via GET /api/v1/customers/me/pix-qr. O endpoint re-busca o PaymentIntent e retorna os dados do QR code. Se o QR expirou (30min), retorna 410 — o customer deve cancelar e criar novo subscribe.
Guard contra duplicatas
O endpoint POST /stripe/subscribe/pix impede criar nova subscription se ja existe uma pending. O customer deve completar o pagamento, aguardar a expiracao (30min), ou cancelar via DELETE /customers/me/subscription (cancela o PaymentIntent no Stripe).
Diferenças Stripe PIX vs Stripe Cartao¶
| Aspecto | PIX | Cartao |
|---|---|---|
| Recorrencia | Pagamento unico anual | Cobranca automatica mensal/anual |
| Rota | POST /stripe/subscribe/pix |
POST /stripe/subscribe |
| Payload | {plan_id, amount} |
{plan_id, payment_method_id} |
| Ativacao | Webhook payment_intent.succeeded |
Imediata ou 3D Secure |
| Expiracao | expires_at = payment + 1 ano |
Sem expiracao (recorrente) |
| Renovacao | Manual (novo PIX) | Automatica |
| ID Stripe | stripe_payment_intent_id (pi_xxx) |
stripe_subscription_id (sub_xxx) |
Erros Comuns (PIX)¶
| HTTP | Erro | Causa | Solucao |
|---|---|---|---|
| 400 | Customer already has active subscription |
Ja tem assinatura ativa | Cancelar antes |
| 400 | Customer must have tax_id (CPF) for PIX |
tax_id ausente no customer |
Atualizar perfil com CPF |
| 400 | Plan not found or not active |
Plano invalido ou desativado | Verificar plan_id |
| 500 | Stripe credentials not configured |
Chave Stripe ausente | Contatar admin |
📊 Mapeamento de Status¶
Stripe → MongoDB¶
| Status Stripe | Status MongoDB | Descrição |
|---|---|---|
active |
active |
Assinatura ativa, pagamentos em dia |
trialing |
active |
Em período de teste (acesso liberado) |
past_due |
suspended |
Pagamento atrasado, aguardando retry |
unpaid |
suspended |
Pagamento não realizado |
canceled |
cancelled |
Assinatura cancelada |
incomplete |
pending |
Aguardando confirmação (3DS) |
incomplete_expired |
expired |
Expirou sem confirmação |
paused |
suspended |
Assinatura pausada |
Comparação com Asaas¶
| Cenário | Asaas | Stripe |
|---|---|---|
| Pagamento aprovado | ACTIVE → active |
active → active |
| Pagamento falhou | SUSPENDED → suspended |
past_due → suspended |
| Cancelado | CANCELLED → cancelled |
canceled → cancelled |
| Aguardando pagamento | PENDING → pending |
incomplete → pending |
| Expirado | EXPIRED → expired |
incomplete_expired → expired |
🔔 Webhooks Stripe¶
O backend processa webhooks Stripe automaticamente em POST /webhooks/stripe.
O frontend NÃO precisa se preocupar com webhooks. O backend atualiza automaticamente:
- Status da assinatura
- Próxima data de cobrança (
next_billing_date) - Falhas de pagamento (
payment_failures) - Chargebacks (auto-suspensão)
Eventos Processados¶
| Evento Stripe | Ação no Backend |
|---|---|
customer.subscription.updated |
Mapear status, atualizar next_billing_date |
customer.subscription.created |
Mesmo que updated |
customer.subscription.deleted |
status='cancelled', limpar acesso |
invoice.paid |
last_payment_at=now, payment_failures=0 |
invoice.payment_failed |
Incrementar payment_failures (suspende após 3 falhas) |
charge.refunded |
Incrementar total_refunded |
charge.dispute.created |
Auto-suspensão + chargeback |
payment_intent.succeeded |
Ativar PIX subscription pendente (expires_at=now+1year) |
payment_intent.payment_failed |
Cancelar PIX subscription pendente |
Ver documentação completa de webhooks em API de Webhooks.
🆚 Stripe vs Asaas — Qual Usar no Frontend?¶
| Critério | Asaas | Stripe |
|---|---|---|
| Disponibilidade | Brasil apenas | Global (195+ países) |
| Métodos de pagamento | Cartão, PIX, Boleto | Cartão, PIX, Apple Pay, Google Pay |
| Integração no Flutter | Dados brutos de cartão enviados ao backend | flutter_stripe (Stripe Elements) — key via GET /stripe/config |
| 3D Secure (SCA) | Não suporta | Suporte nativo |
| Troca de cartão | Cancel + Recriar | In-place (sem cancelar) |
| Cancelamento | Imediato | Fim do período (gracioso) |
| Endereço obrigatório | Não | Não |
| CVV separado | Não | Não (SDK cuida) |
| Moeda | BRL | Multi-moeda |
Recomendação¶
- Clientes brasileiros com PIX/Boleto: Use Asaas (gateway BR nativo)
- Clientes internacionais ou que preferem cartão: Use Stripe
- O frontend deve oferecer ambas as opções na tela de checkout
Admin — Gerenciamento de Subscriptions Stripe¶
O admin gerencia subscriptions Stripe via rotas dedicadas em /api/v1/admin/stripe/.
Menus Separados
O frontend admin deve ter dois menus distintos: um para Asaas (/admin/asaas/) e outro para Stripe (/admin/stripe/). Cada menu lista e opera apenas subscriptions do respectivo gateway.
Rotas Admin Stripe¶
| Metodo | Endpoint | Descricao |
|---|---|---|
GET |
/api/v1/admin/stripe/subscriptions |
Listar subscriptions Stripe |
GET |
/api/v1/admin/stripe/subscriptions/stats |
Estatisticas (MRR, ativos, etc.) |
POST |
/api/v1/admin/stripe/subscriptions/{id}/cancel |
Cancelar imediatamente |
GET |
/api/v1/admin/stripe/subscriptions/{id}/payments |
Charges reais via Stripe API |
POST |
/api/v1/admin/stripe/subscriptions/{id}/refund |
Estornar pagamento |
GET |
/api/v1/admin/stripe/subscriptions/{id}/refunds |
Historico de estornos |
Cancelamento Admin vs Customer¶
| Contexto | Comportamento |
|---|---|
Customer (DELETE /stripe/subscription) |
Cancela no fim do periodo (cancel_at_period_end=True) — customer mantem acesso ate expirar |
Admin (POST /admin/stripe/subscriptions/{id}/cancel) |
Cancela imediatamente (at_period_end=False) — acesso removido na hora |
Refund com Auto-Deteccao¶
O endpoint de refund aceita payment_intent_id opcional. Se omitido, o sistema auto-detecta:
- Busca
stripe_customer_iddo customer - Lista charges via
Charge.list(customer=cus_xxx) - Seleciona o primeiro charge
succeedede nao estornado - Usa o
payment_intentdesse charge
POST /api/v1/admin/stripe/subscriptions/507f.../refund
Authorization: Bearer <admin_token>
Content-Type: application/json
{
"reason": "Customer solicitou reembolso"
}
Gestao de Planos para Stripe¶
Planos podem ser publicados para Stripe ao criar ou sincronizar posteriormente:
# Criar plano para ambos os gateways
POST /api/v1/plans
{
"plan_id": "plan-basic",
"name": "Plano Basico",
"amount": 2990,
"interval_unit": "MONTH",
"publish_to": ["stripe", "asaas"]
}
# Sincronizar plano existente com Stripe
POST /api/v1/plans/plan-basic/sync-stripe
Ver documentacao completa em Plans API e Admin Dashboard.
Testes (Sandbox)¶
Cartões de Teste Stripe¶
| Número | Cenário |
|---|---|
4242 4242 4242 4242 |
Pagamento aprovado |
4000 0025 0000 3155 |
Requer 3D Secure |
4000 0000 0000 9995 |
Pagamento recusado |
4000 0000 0000 0341 |
Disputa (chargeback) |
Dados de teste:
- Validade: qualquer data futura (ex: 12/2030)
- CVC: qualquer 3 dígitos (ex: 123)
- CEP: qualquer (ex: 12345)
Modo Teste vs Produção
As chaves Stripe com prefixo pk_test_ / sk_test_ operam no modo teste. Em produção, use pk_live_ / rk_live_. A publishable_key é servida pelo backend via GET /api/v1/stripe/config — o frontend nunca deve hardcodar essa chave. A secret_key do backend é uma restricted key (rk_live_) configurada em credentials.toml.
⚙️ Configuração Backend — credentials.toml¶
As chaves Stripe ficam centralizadas no credentials.toml (.secrets/credentials.toml). O backend serve a publishable_key ao frontend via GET /api/v1/stripe/config.
[production.stripe]
# ID da chave publicável no Stripe Dashboard (referência, não usado em código)
publishable_key_id = "mk_xxx"
# Chave publicável — servida ao frontend via GET /api/v1/stripe/config
publishable_key = "pk_live_xxx"
# Restricted key — usada pelo backend para chamadas à API Stripe
secret_key = "rk_live_xxx"
| Campo | Prefixo | Onde é usado | Onde encontrar |
|---|---|---|---|
publishable_key_id |
mk_ |
Referência interna (não usado em código) | Dashboard → API Keys → coluna "Token" da Chave publicável |
publishable_key |
pk_live_ |
Frontend (via endpoint /stripe/config) |
Dashboard → API Keys → clicar "Revelar chave" na Chave publicável |
secret_key |
rk_live_ |
Backend (chamadas server-side) | Dashboard → Chaves restritas → "Revelar chave" |
Após alterar as chaves
- Atualize o
CREDENTIALS_TOMLno GitHub Secrets (Settings → Secrets → Actions) - Faça redeploy do backend (GitHub Actions ou manual)
- O frontend não precisa de rebuild — busca a key nova automaticamente via
/stripe/config
📐 Fluxo Completo — Diagrama¶
Signup + Escolha de Gateway¶
flowchart TD
A[Customer faz login] --> B{Tem assinatura ativa?}
B -->|Sim| C[Acesso liberado a vídeos]
B -->|Não| D[Tela de Planos]
D --> E[Escolhe plano]
E --> F{Qual gateway?}
F -->|Asaas| G[Formulário Asaas]
G --> G3["POST /customers/me/subscribe<br/>{card_number, card_holder, card_cvv, card_exp_month, card_exp_year}"]
G3 --> H[Assinatura criada]
F -->|Stripe| I[GET /stripe/config → publishable_key]
I --> I1[Stripe.init publishable_key]
I1 --> I2[Stripe.createPaymentMethod]
I2 --> I3["POST /stripe/subscribe<br/>{plan_id, payment_method_id}"]
I3 --> J{client_secret?}
J -->|Não| H
J -->|Sim| K[confirmPayment 3DS]
K --> H
H --> C
⚠️ Tratamento de Erros¶
Erros Comuns¶
| HTTP | Erro | Causa | Solução |
|---|---|---|---|
| 400 | Customer already has an active subscription |
Customer já tem assinatura ativa | Cancelar assinatura atual antes |
| 400 | Plan does not have Stripe pricing configured |
Plan sem stripe_price_id |
Admin deve configurar o plano no Stripe |
| 400 | Current subscription is not managed by Stripe |
Tentou usar endpoint Stripe com assinatura de outro gateway | Usar endpoints Asaas |
| 404 | Customer not found |
user_type != 'customer' | Verificar autenticação |
| 404 | Plan not found |
plan_id inválido | Usar GET /api/v1/plans para listar planos |
| 404 | No active subscription |
Sem assinatura para consultar/cancelar | Criar assinatura primeiro |
| 500 | Stripe credentials not configured |
Chaves Stripe ausentes no backend | Contatar admin |
Exemplo de Tratamento no Flutter¶
try {
final result = await stripeService.subscribe(planId: 'plan-basic');
if (result['client_secret'] != null) {
// 3D Secure
await Stripe.instance.confirmPayment(
paymentIntentClientSecret: result['client_secret'],
);
}
showSuccess('Assinatura ativada!');
} on StripeException catch (e) {
// Erro do Stripe SDK (cartão inválido, 3DS falhou, etc.)
showError(e.error.localizedMessage ?? 'Erro no pagamento');
} catch (e) {
// Erro da API (400, 404, 500, etc.)
showError(e.toString());
}
✅ Checklist de Implementação Frontend (Stripe)¶
- [ ] Adicionar
flutter_stripeaopubspec.yaml - [ ] Configurar minSdkVersion (Android 21+) e iOS 13+
- [ ] Buscar publishable key via
GET /api/v1/stripe/config(NUNCA hardcodar) - [ ] Inicializar
Stripe.publishableKeycom a key obtida do backend - [ ] Implementar
CardFieldwidget para coleta segura de cartão - [ ] Implementar
createPaymentMethod()para obterpm_xxx - [ ] Implementar subscribe (
POST /api/v1/stripe/subscribe) - [ ] Tratar resposta com
client_secret(3D Secure) - [ ] Implementar consulta de assinatura (
GET /api/v1/stripe/subscription) - [ ] Implementar cancelamento (
DELETE /api/v1/stripe/subscription) - [ ] Implementar troca de método de pagamento (
PUT /api/v1/stripe/subscription/payment-method) - [ ] Tratar todos os erros HTTP (400, 404, 500) e
StripeException - [ ] Testar com cartões de teste (4242..., 3DS, recusa)
- [ ] Implementar PIX subscribe (
POST /api/v1/stripe/subscribe/pix) - [ ] Exibir QR code PIX (imagem + copia-e-cola)
- [ ] Tratar expiracao do QR code (30 minutos)