Skip to content

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

# pubspec.yaml
dependencies:
  flutter_stripe: ^11.0.0  # Stripe SDK oficial para Flutter
  http: ^1.1.0
flutter pub get

Configuração Nativa

O flutter_stripe requer configuração nativa adicional:

Android (android/app/build.gradle):

android {
    compileSdkVersion 34
    defaultConfig {
        minSdkVersion 21  // Mínimo para Stripe
    }
}

iOS (ios/Podfile):

platform :ios, '13.0'  # Mínimo para Stripe

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

GET /api/v1/stripe/config

Response (200):

{
  "publishable_key": "pk_live_xxx..."
}

Response (500 — chave não configurada):

{
  "detail": "Stripe publishable key not configured on server."
}

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

GET /api/v1/stripe/subscription
Authorization: Bearer eyJhbGci...

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):

{
  "detail": "No active subscription found for this customer"
}

Response (400 — assinatura não é Stripe):

{
  "detail": "Current subscription is not managed by 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):

{
  "message": "Payment method updated successfully.",
  "updated_at": "2026-02-27T15:00:00Z"
}

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

DELETE /api/v1/stripe/subscription
Authorization: Bearer eyJhbGci...

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 ACTIVEactive activeactive
Pagamento falhou SUSPENDEDsuspended past_duesuspended
Cancelado CANCELLEDcancelled canceledcancelled
Aguardando pagamento PENDINGpending incompletepending
Expirado EXPIREDexpired incomplete_expiredexpired

🔔 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:

  1. Busca stripe_customer_id do customer
  2. Lista charges via Charge.list(customer=cus_xxx)
  3. Seleciona o primeiro charge succeeded e nao estornado
  4. Usa o payment_intent desse 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

  1. Atualize o CREDENTIALS_TOML no GitHub Secrets (Settings → Secrets → Actions)
  2. Faça redeploy do backend (GitHub Actions ou manual)
  3. 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_stripe ao pubspec.yaml
  • [ ] Configurar minSdkVersion (Android 21+) e iOS 13+
  • [ ] Buscar publishable key via GET /api/v1/stripe/config (NUNCA hardcodar)
  • [ ] Inicializar Stripe.publishableKey com a key obtida do backend
  • [ ] Implementar CardField widget para coleta segura de cartão
  • [ ] Implementar createPaymentMethod() para obter pm_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)